Paginate workflow run-form data dropdowns via /api/histories/{id}/contents

Switch the simplified and legacy workflow run forms from a JSON-encoded
``options_pagination`` round-trip on ``/api/workflows/{id}/download?style=run``
to a direct ``GET /api/histories/{history_id}/contents`` call per scroll /
search. Workflow ``data`` / ``data_collection`` inputs cannot use the
Python-only filter knobs that tool ``data`` parameters can (``options_filter_attribute``,
``data_destination``), so SQL-level filtering on extension + state + name/hid
is sufficient — no need for the workflow API to mediate every page fetch.

The expanded "acceptable extensions" set (formats ∪ implicit-conversion
sources, already computed by ``BaseDataToolParameter._acceptable_extensions``)
is shipped once in the initial run spec; the client passes it to the contents
API as ``q=extension-in&qv=ext1,ext2,...``.

Add SQL extension filtering to ``History.paginated_active_dataset_collections``
and to ``HistoryContentsFilters`` for HDCAs: a correlated subquery walks up to
three levels of nesting (``list``, ``paired``, ``list:paired``, ``list:list``,
``list:list:paired``) and rejects collections that contain any leaf HDA whose
extension is outside the acceptable set — matching the canonical
``SummaryDatasetCollectionMatcher`` semantics without a schema migration.
This commit is contained in:
mvdbeek
2026-05-21 08:31:21 +02:00
parent 5ae6ee1c35
commit 125e253eba
11 changed files with 615 additions and 56 deletions
+2 -2
View File
@@ -80,8 +80,8 @@ const emit = defineEmits<{
(e: "onValidation", validation: [string, string] | null): void;
(e: "stop-flagging"): void;
(e: "update:active-node-id", id: number): void;
(e: "load-more", payload: unknown): void;
(e: "search-change", payload: unknown): void;
(e: "load-more", payload: { name: string; src: string; offset: number; limit: number; search?: string }): void;
(e: "search-change", payload: { name: string; src: string; query: string; limit: number }): void;
}>();
const {
@@ -84,6 +84,7 @@
v-else
:model="step"
:validation-scroll-to="getValidationScrollTo(step.index)"
:history-id="currentHistoryId"
@onChange="onDefaultStepInputs"
@onValidation="onValidation" />
</div>
@@ -26,7 +26,7 @@ import type { Step } from "@/stores/workflowStepStore";
import localize from "@/utils/localization";
import { errorMessageAsString } from "@/utils/simple-error";
import { invokeWorkflow } from "./services";
import { invokeWorkflow, searchHistoryContents } from "./services";
import WorkflowAnnotation from "../WorkflowAnnotation.vue";
import WorkflowNavigationTitle from "../WorkflowNavigationTitle.vue";
@@ -133,8 +133,23 @@ const computedActiveNodeId = computed<number | undefined>(() => {
return undefined;
});
const formInputs = computed(() => {
const inputs = [] as any[];
// Build the form inputs once into a stable ref so paginated mutations
// (``onLoadMore`` / ``onSearchChange`` set ``input.options`` / ``options_meta``
// on the matching step-input object below) don't rebuild the array. Vue's
// ``v-for`` in the child ``FormDisplay`` then doesn't unmount the dropdown's
// ``<input>`` element across paginated refreshes — important for selenium
// tests like ``test_workflow_rerun`` that ``select_set_value`` against the
// dropdown (type → wait UX_RENDER → send Enter on the same element ref).
//
// ``stepInputByIndex`` maps ``step.step_index`` (as string) to the live
// step-input object inside ``formInputs.value`` so the paginated-fetch
// handlers can locate and mutate it in O(1) without walking the array.
const formInputs = ref<any[]>([]);
const stepInputByIndex = new Map<string, any>();
function buildFormInputs() {
const inputs: any[] = [];
stepInputByIndex.clear();
// Add workflow parameters.
Object.values(props.model.wpInputs).forEach((input) => {
const inputCopy = Object.assign({}, input) as any;
@@ -195,11 +210,15 @@ const formInputs = computed(() => {
// disable collection mapping...
stepAsInput.flavor = "module";
inputs.push(stepAsInput);
stepInputByIndex.set(String(step.step_index), stepAsInput);
inputTypes.value[stepName] = stepType;
}
});
return inputs;
});
formInputs.value = inputs;
}
buildFormInputs();
watch(() => [props.model, props.requestState], buildFormInputs);
/**
* Returns the list of steps that do not match the workflow rerun `props.requestState`.
@@ -287,6 +306,93 @@ function onChange(data: any) {
formData.value = data;
}
function shapeContentsRow(row: any) {
const src = row.history_content_type === "dataset_collection" ? "hdca" : "hda";
return {
id: row.id,
src,
name: row.name,
hid: row.hid,
keep: false,
tags: row.tags || [],
};
}
async function fetchStepOptions(
name: string,
src: string,
payload: { offset?: number; limit?: number; search?: string } = {},
mode: "append" | "replace" = "append",
) {
// Locate the live step-input object inside ``formInputs.value`` and
// mutate it in place — ``formInputs`` is a stable ref built once, so
// mutating ``input.options`` / ``input.options_meta`` doesn't rebuild
// the array and doesn't unmount the dropdown's ``<input>`` element.
// (``stepAsInput`` is a local copy built in ``buildFormInputs``; this
// is not prop mutation.)
const input = stepInputByIndex.get(String(name));
if (!input) {
return;
}
const type = src === "hdca" ? "dataset_collection" : "dataset";
const extensions = (input.acceptable_extensions || []) as string[];
const limit = payload.limit || 50;
const offset = payload.offset || 0;
try {
const rows = await searchHistoryContents(props.model.historyId, {
extensions,
type,
search: payload.search,
offset,
limit,
});
const shaped = (rows || []).map(shapeContentsRow);
let merged: any[];
if (mode === "replace") {
merged = shaped;
} else {
const seen = new Set<string>();
const base = (input.options?.[src] as any[]) || [];
merged = [...base, ...shaped].filter((item) => {
const k = `${item.id}_${item.src}`;
if (seen.has(k)) {
return false;
}
seen.add(k);
return true;
});
}
input.options = { ...(input.options || {}), [src]: merged };
input.options_meta = {
...(input.options_meta || {}),
[src]: { offset, limit, has_more: shaped.length === limit },
};
} catch (e) {
// intentionally silent — paging failures don't block the rest of the form
console.warn("history-contents pagination failed", e);
}
}
function onLoadMore({
name,
src,
offset,
limit,
search,
}: {
name: string;
src: string;
offset: number;
limit: number;
search?: string;
}) {
fetchStepOptions(name, src, { offset, limit, search }, "append");
}
function onSearchChange({ name, src, query, limit }: { name: string; src: string; query: string; limit?: number }) {
fetchStepOptions(name, src, { offset: 0, limit: limit || 50, search: query }, "replace");
}
function onStorageUpdate(objectStoreId: string, intermediate: boolean) {
if (intermediate) {
preferredIntermediateObjectStoreId.value = objectStoreId;
@@ -616,6 +722,8 @@ onBeforeMount(() => {
:steps-not-matching-request="stepsNotMatchingRequest"
@onChange="onChange"
@onValidation="onValidation"
@load-more="onLoadMore"
@search-change="onSearchChange"
@stop-flagging="checkInputMatching = false"
@update:active-node-id="updateActiveNodeId" />
</GOverlay>
@@ -7,7 +7,9 @@
:inputs="inputs"
:validation-scroll-to="validationScrollTo"
@onChange="onChange"
@onValidation="onValidation" />
@onValidation="onValidation"
@load-more="onLoadMore"
@search-change="onSearchChange" />
<div v-else class="py-2">No options available.</div>
</template>
</FormCard>
@@ -17,6 +19,8 @@
<script>
import WorkflowIcons from "@/components/Workflow/icons";
import { searchHistoryContents } from "./services";
import FormCard from "@/components/Form/FormCard.vue";
import FormDisplay from "@/components/Form/FormDisplay.vue";
@@ -34,10 +38,25 @@ export default {
type: Array,
required: true,
},
historyId: {
type: String,
default: null,
},
},
data() {
// Shallow-copy ``model.inputs`` into local state so we can mutate
// ``options`` / ``options_meta`` (and the existing ``flavor`` /
// ``hide_label`` flags) without touching the prop — avoiding the
// Vue prop-mutation antipattern. Each ``localInputs[i]`` is a fresh
// object; the nested ``options`` object reference is shared until
// ``_fetchStepOptions`` replaces it with a new object via spread.
return {
expanded: this.model.expanded,
localInputs: (this.model.inputs || []).map((input) => ({
...input,
flavor: "module",
hide_label: this._isSimpleInputType(this.model.step_type),
})),
};
},
computed: {
@@ -45,20 +64,19 @@ export default {
return WorkflowIcons[this.model.step_type];
},
isSimpleInput() {
return (
this.model.step_type.startsWith("data_input") ||
this.model.step_type.startsWith("data_collection_input")
);
return this._isSimpleInputType(this.model.step_type);
},
inputs() {
this.model.inputs.forEach((input) => {
input.flavor = "module";
input.hide_label = this.isSimpleInput;
});
if (this.model.inputs && this.model.inputs.length > 0) {
return this.model.inputs;
}
return [];
// Keep the array reference stable across paginated refreshes:
// ``_fetchStepOptions`` mutates a single ``localInputs[i]``'s
// ``options`` / ``options_meta`` properties (not the array), so
// Vue's ``v-for`` in the child ``FormDisplay`` doesn't unmount
// its rendered children. That matters because
// ``select_set_value`` (used by ``test_execution_with_multiple_inputs``)
// types into vue-multiselect's input, sleeps ``UX_RENDER``, then
// sends Enter on the same element reference — a remount during
// that window would invalidate it.
return this.localInputs;
},
hasInputs() {
return this.inputs.length > 0;
@@ -70,6 +88,17 @@ export default {
this.expanded = true;
}
},
"model.inputs"() {
// Re-sync local copy if the parent ever replaces the model. The
// workflow run form doesn't currently do this mid-render, but
// keep the contract: ``localInputs`` mirrors ``model.inputs``
// until paginated mutations diverge from it.
this.localInputs = (this.model.inputs || []).map((input) => ({
...input,
flavor: "module",
hide_label: this._isSimpleInputType(this.model.step_type),
}));
},
},
methods: {
onChange(data) {
@@ -79,6 +108,78 @@ export default {
onValidation(validation) {
this.$emit("onValidation", this.model.index, validation);
},
_isSimpleInputType(stepType) {
return stepType.startsWith("data_input") || stepType.startsWith("data_collection_input");
},
_findInputByName(name) {
return (this.localInputs || []).find((i) => i.name === name);
},
_shapeContentsRow(row) {
const src = row.history_content_type === "dataset_collection" ? "hdca" : "hda";
return {
id: row.id,
src,
name: row.name,
hid: row.hid,
keep: false,
tags: row.tags || [],
};
},
async _fetchStepOptions(name, src, payload = {}, mode = "append") {
const input = this._findInputByName(name);
if (!input || !this.historyId) {
return;
}
const type = src === "hdca" ? "dataset_collection" : "dataset";
const extensions = input.acceptable_extensions || [];
const limit = payload.limit || 50;
const offset = payload.offset || 0;
try {
const rows = await searchHistoryContents(this.historyId, {
extensions,
type,
search: payload.search,
offset,
limit,
});
const shaped = (rows || []).map(this._shapeContentsRow);
let merged;
if (mode === "replace") {
merged = shaped;
} else {
const base = (input.options && input.options[src]) || [];
const seen = new Set();
merged = [...base, ...shaped].filter((item) => {
const k = `${item.id}_${item.src}`;
if (seen.has(k)) {
return false;
}
seen.add(k);
return true;
});
}
// Mutate the local copy in place; ``localInputs`` is
// component-owned (declared in ``data()``), so this is not
// prop mutation. The ``localInputs`` array reference stays
// stable across paginated refreshes — Vue's ``v-for`` in the
// child ``FormDisplay`` doesn't unmount its children, so
// vue-multiselect's ``<input>`` survives across the
// search-change debounce.
input.options = { ...(input.options || {}), [src]: merged };
input.options_meta = {
...(input.options_meta || {}),
[src]: { offset, limit, has_more: shaped.length === limit },
};
} catch (e) {
console.warn("history-contents pagination failed", e);
}
},
onLoadMore({ name, src, offset, limit, search }) {
this._fetchStepOptions(name, src, { offset, limit, search }, "append");
},
onSearchChange({ name, src, query, limit }) {
this._fetchStepOptions(name, src, { offset: 0, limit: limit || 50, search: query }, "replace");
},
},
};
</script>
@@ -26,6 +26,62 @@ export async function getRunData(workflowId, version = null, instance = false) {
}
}
/**
* Search history contents (HDAs + HDCAs) for a workflow run dropdown. Filters
* server-side by extension (canonical accept-set including implicit conversion
* targets), name/hid (search), and HDA-vs-HDCA type. Returns the raw API list.
*
* @param {String} historyId
* @param {Object} opts
* @param {Array<string>} [opts.extensions] - sorted accept-set; empty/missing → no extension filter.
* @param {String} [opts.type] - "dataset" | "dataset_collection".
* @param {String} [opts.search] - name substring or numeric hid match.
* @param {Number} [opts.offset]
* @param {Number} [opts.limit]
*/
export async function searchHistoryContents(historyId, { extensions, type, search, offset = 0, limit = 50 } = {}) {
const q = [];
const qv = [];
q.push("visible-eq");
qv.push("True");
q.push("deleted-eq");
qv.push("False");
if (type) {
q.push("history_content_type-eq");
qv.push(type);
}
if (extensions && extensions.length) {
q.push("extension-in");
qv.push(extensions.join(","));
}
if (search) {
const trimmed = String(search).trim();
if (trimmed) {
if (/^\d+$/.test(trimmed)) {
q.push("hid-eq");
qv.push(trimmed);
} else {
q.push("name-contains");
qv.push(trimmed);
}
}
}
const params = new URLSearchParams();
params.set("v", "dev");
params.set("offset", String(offset));
params.set("limit", String(limit));
params.set("order", "hid-dsc");
q.forEach((key) => params.append("q", key));
qv.forEach((value) => params.append("qv", value));
try {
const url = `${getAppRoot()}api/histories/${historyId}/contents?${params.toString()}`;
const response = await axios.get(url);
return response.data;
} catch (e) {
rethrowSimple(e);
}
}
/**
* Invoke the specified workflow using the supplied data.
*
+67 -18
View File
@@ -7,6 +7,7 @@ import json
import logging
from typing import (
Any,
Optional,
)
from sqlalchemy import (
@@ -601,29 +602,40 @@ class HistoryContentsFilters(
):
# surprisingly (but ominously), this works for both content classes in the union that's filtered
model_class = model.HistoryDatasetAssociation
# Threaded from ``parse_query_filters_with_relations`` so the depth-arbitrary
# ``extension`` HDCA clause can scope its recursive CTE to the current
# history. ``None`` means the filter is being parsed outside a history
# scope (e.g. ``/api/datasets``); in that case the HDCA branch of the
# extension filter degrades to a no-op (HDA filter still applies).
_current_history_id: Optional[int] = None
def parse_query_filters_with_relations(self, query_filters: ValueFilterQueryParams, history_id):
"""Parse query filters but consider case where related filter is included."""
has_related_q = [q for q in ("related-eq", "related") if query_filters.q and q in query_filters.q]
if query_filters.q and query_filters.qv and has_related_q:
qv_index = query_filters.q.index(has_related_q[0])
qv_hid = query_filters.qv[qv_index]
prev_history_id = self._current_history_id
self._current_history_id = history_id
try:
has_related_q = [q for q in ("related-eq", "related") if query_filters.q and q in query_filters.q]
if query_filters.q and query_filters.qv and has_related_q:
qv_index = query_filters.q.index(has_related_q[0])
qv_hid = query_filters.qv[qv_index]
# Type check whether hid is int
if not qv_hid.isdigit():
raise glx_exceptions.RequestParameterInvalidException(
"unparsable value for related filter",
column="related",
operation="eq",
value=qv_hid,
ValueError="invalid type in filter",
# Type check whether hid is int
if not qv_hid.isdigit():
raise glx_exceptions.RequestParameterInvalidException(
"unparsable value for related filter",
column="related",
operation="eq",
value=qv_hid,
ValueError="invalid type in filter",
)
query_filters_with_relations = self.get_query_filters_with_relations(
query_filters=query_filters, related_q=has_related_q[0], history_id=history_id
)
query_filters_with_relations = self.get_query_filters_with_relations(
query_filters=query_filters, related_q=has_related_q[0], history_id=history_id
)
return super().parse_query_filters(query_filters_with_relations)
return super().parse_query_filters(query_filters)
return super().parse_query_filters(query_filters_with_relations)
return super().parse_query_filters(query_filters)
finally:
self._current_history_id = prev_history_id
def _parse_orm_filter(self, attr, op, val):
# we need to use some manual/text/column fu here since some where clauses on the union don't work
@@ -699,6 +711,39 @@ class HistoryContentsFilters(
raise_filter_err(attr, op, val, "bad op in filter")
if attr == "extension" and op in ("eq", "in"):
# Apply different filter expressions to the HDA and HDCA branches
# of the union: HDAs filter by their own ``extension`` column,
# while HDCAs go through the depth-arbitrary recursive-CTE clause
# built by ``History._hdca_extensions_only_in_clause`` (a
# collection matches only when every leaf HDA extension is in the
# set, mirroring ``SummaryDatasetCollectionMatcher.hdca_match``).
# The CTE needs the current history_id; ``parse_query_filters_with_relations``
# stashes it on ``self``. When parsed without a history scope
# (e.g. ``/api/datasets``), the HDCA branch degrades to a no-op
# — HDA filtering still applies, which covers the documented
# behavior of ``/api/datasets``. Other ops (``like``) fall through
# to the base parser's standard column filter (HDCAs are excluded
# there because the union's HDCA branch has ``extension=null()``).
if op == "eq":
extensions = {val}
else:
extensions = {e for e in val.split(",") if e}
if not extensions:
raise_filter_err(attr, op, val, "empty extension list")
history_id = self._current_history_id
def extension_filter(component_class):
if component_class is model.HistoryDatasetAssociation:
return model.HistoryDatasetAssociation.extension.in_(extensions)
if component_class is model.HistoryDatasetCollectionAssociation:
if history_id is None:
return true()
return model.History._hdca_extensions_only_in_clause(extensions, history_id)
return None
return self.parsed_filter(filter_type="orm_function", filter=extension_filter)
if (column_filter := get_filter(attr, op, val)) is not None:
return self.parsed_filter(filter_type="orm", filter=column_filter)
return super()._parse_orm_filter(attr, op, val)
@@ -754,6 +799,10 @@ class HistoryContentsFilters(
# 'hid-in' : { 'op': ( 'in' ), 'val': self.parse_int_list },
"name": {"op": ("eq", "contains", "like")},
"state": {"op": ("eq", "in")},
# ``eq`` / ``in`` are handled specially in ``_parse_orm_filter``
# so the HDCA branch can use a depth-arbitrary recursive CTE;
# ``like`` falls through to the base parser's column filter.
"extension": {"op": ("eq", "like", "in"), "val": {"in": lambda v: v.split(",")}},
"object_store_id": {"op": ("eq", "in")},
"quota_source_label": {"op": ("eq")},
"visible": {"op": ("eq"), "val": parse_bool},
+12 -1
View File
@@ -1172,7 +1172,18 @@ class WorkflowContentsManager(UsesAnnotations):
]
else:
inputs = step.module.get_runtime_inputs(step, connections=step.output_connections)
step_model = {"inputs": [input.to_dict(trans) for input in inputs.values()]}
input_dicts = []
for input in inputs.values():
input_dict = input.to_dict(trans)
# Frontend paginates ``data`` / ``data_collection`` workflow
# input dropdowns directly against ``/api/histories/{id}/contents``;
# ship the precomputed accept-set (formats ∪ implicit-conversion
# sources) so the client can pass ``q=extension-in&qv=…``.
if isinstance(input, (DataToolParameter, DataCollectionToolParameter)):
accepted = input._acceptable_extensions()
input_dict["acceptable_extensions"] = sorted(accepted) if accepted else []
input_dicts.append(input_dict)
step_model = {"inputs": input_dicts}
step_model["when"] = step.when_expression
step_model["replacement_parameters"] = step.module.get_informal_replacement_parameters(step)
step_model["step_type"] = step.type
+62 -6
View File
@@ -4104,6 +4104,61 @@ class History(Base, HasTags, UsesAnnotations, HasName, Serializable, UsesCreateA
total = session.scalar(count_stmt) or 0
return rows, total
@staticmethod
def _hdca_leaf_hda_descendants_cte(history_id):
"""Recursive CTE: for every non-deleted HDCA in ``history_id``, walk
``dataset_collection_element`` arbitrarily deep to enumerate every
leaf HDA. Columns: ``root_hdca_id``, ``hda_id``,
``child_collection_id``. Used by depth-arbitrary HDCA filters (e.g.
extension match) — single source of truth for "walk all leaves of
the HDCAs in this history". Works on PostgreSQL and SQLite.
"""
base = (
select(
HistoryDatasetCollectionAssociation.id.label("root_hdca_id"),
DatasetCollectionElement.hda_id.label("hda_id"),
DatasetCollectionElement.child_collection_id.label("child_collection_id"),
)
.select_from(HistoryDatasetCollectionAssociation)
.join(
DatasetCollectionElement,
DatasetCollectionElement.dataset_collection_id == HistoryDatasetCollectionAssociation.collection_id,
)
.where(
HistoryDatasetCollectionAssociation.history_id == history_id,
not_(HistoryDatasetCollectionAssociation.deleted),
)
)
cte = base.cte(name="hdca_leaf_hda_descendants", recursive=True)
dce_rec = aliased(DatasetCollectionElement)
cte = cte.union_all(
select(
cte.c.root_hdca_id,
dce_rec.hda_id,
dce_rec.child_collection_id,
)
.select_from(cte)
.join(dce_rec, dce_rec.dataset_collection_id == cte.c.child_collection_id)
)
return cte
@staticmethod
def _hdca_extensions_only_in_clause(extensions: set[str], history_id):
"""Build a WHERE clause: True for HDCAs whose every leaf HDA's
extension is in ``extensions`` (mirrors
``SummaryDatasetCollectionMatcher.hdca_match``). Walks arbitrary
depth via :py:meth:`_hdca_leaf_hda_descendants_cte`.
"""
cte = History._hdca_leaf_hda_descendants_cte(history_id)
hda_check = aliased(HistoryDatasetAssociation)
bad_hdca_ids = (
select(cte.c.root_hdca_id)
.select_from(cte)
.join(hda_check, hda_check.id == cte.c.hda_id)
.where(hda_check.extension.notin_(extensions))
)
return HistoryDatasetCollectionAssociation.id.notin_(bad_hdca_ids)
def paginated_active_dataset_collections(
self,
*,
@@ -4112,12 +4167,13 @@ class History(Base, HasTags, UsesAnnotations, HasName, Serializable, UsesCreateA
offset: int = 0,
limit: int = 50,
) -> tuple[list["HistoryDatasetCollectionAssociation"], int]:
"""Active HDCAs paginated. Extension filtering for collections is handled
in Python by the dataset-collection matcher because collections aggregate
per-element extensions. Pass ``visible_only=False`` to include hidden
collections (matches the legacy ``active_dataset_collections`` semantics
used by some tool-form paths). ``search`` matches case-insensitively
against the collection name and (when numeric) against the hid.
"""Active HDCAs paginated. Pass ``visible_only=False`` to include
hidden collections (matches the legacy ``active_dataset_collections``
semantics used by some tool-form paths). ``search`` matches
case-insensitively against the collection name and (when numeric)
against the hid. Extension filtering for collections is exposed via
the history-contents filter parser (see
:py:meth:`_hdca_extensions_only_in_clause`).
"""
filters = [
HistoryDatasetCollectionAssociation.history_id == self.id,
@@ -1041,6 +1041,130 @@ class TestHistoryContentsApi(ApiTestCase):
collection = contents_response.json()[0]
assert sorted(collection["elements_datatypes"]) == sorted(expected_datatypes)
def test_index_filter_by_extension(self, history_id):
self.dataset_populator.new_dataset(history_id, file_type="bed", wait=True)
self.dataset_populator.new_dataset(history_id, file_type="bed", wait=True)
self.dataset_populator.new_dataset(history_id, file_type="tabular", wait=True)
self.dataset_populator.new_dataset(history_id, file_type="txt", wait=True)
# extension-eq (single value): only matching HDAs
response = self._get(f"histories/{history_id}/contents?v=dev&q=extension-eq&qv=bed").json()
bed_ids = {item["id"] for item in response}
assert len(bed_ids) == 2
# extension-in (multiple values, comma-separated)
response = self._get(f"histories/{history_id}/contents?v=dev&q=extension-in&qv=bed,tabular").json()
ids = {item["id"] for item in response}
assert len(ids) == 3
def test_index_filter_collections_by_extension(self, history_id):
all_bed = [
{"name": "a", "src": "pasted", "paste_content": "chr1\t1\t10", "ext": "bed"},
{"name": "b", "src": "pasted", "paste_content": "chr1\t1\t10", "ext": "bed"},
]
self._upload_collection_list_with_elements(history_id, "all_bed", all_bed)
mixed = [
{"name": "a", "src": "pasted", "paste_content": "chr1\t1\t10", "ext": "bed"},
{"name": "b", "src": "pasted", "paste_content": "abc", "ext": "txt"},
]
self._upload_collection_list_with_elements(history_id, "mixed", mixed)
all_txt = [
{"name": "a", "src": "pasted", "paste_content": "abc", "ext": "txt"},
{"name": "b", "src": "pasted", "paste_content": "abc", "ext": "txt"},
]
self._upload_collection_list_with_elements(history_id, "all_txt", all_txt)
# extension-eq matches only the all-bed collection (mixed has a non-bed leaf)
response = self._get(
f"histories/{history_id}/contents?v=dev"
"&q=history_content_type-eq&qv=dataset_collection"
"&q=extension-eq&qv=bed"
).json()
names = {c["name"] for c in response}
assert names == {"all_bed"}, names
# extension-in with bed + txt accepts both the bed and txt collections AND the mixed
# collection (all of its leaves are within the acceptable set).
response = self._get(
f"histories/{history_id}/contents?v=dev"
"&q=history_content_type-eq&qv=dataset_collection"
"&q=extension-in&qv=bed,txt"
).json()
names = {c["name"] for c in response}
assert names == {"all_bed", "mixed", "all_txt"}, names
def test_index_filter_by_name_unique_sentinel(self, history_id):
"""Real-DB integration coverage for ``History.paginated_active_visible_datasets``
name-search path — a unique sentinel name must return exactly one HDA
(no substring leakage).
"""
for i in range(20):
self.dataset_populator.new_dataset(history_id, name=f"Sample {i}", wait=True)
sentinel = "unique-zebra-xyz"
self.dataset_populator.new_dataset(history_id, name=sentinel, wait=True)
response = self._get(f"histories/{history_id}/contents?v=dev&q=name-contains&qv=zebra").json()
assert len(response) == 1, response
assert response[0]["name"] == sentinel
def test_index_filter_by_hid_exact(self, history_id):
"""``q=hid-eq`` returns the HDA with the requested hid — covers the
numeric branch of ``paginated_active_visible_datasets.search``.
"""
for i in range(5):
self.dataset_populator.new_dataset(history_id, name=f"D{i}", wait=True)
response = self._get(f"histories/{history_id}/contents?v=dev&q=hid-eq&qv=3").json()
assert len(response) == 1, response
assert response[0]["hid"] == 3
def test_index_filter_collections_by_extension_nested(self, history_id):
"""Depth-arbitrary regression test for the ``extension`` HDCA filter
(recursive CTE in ``History._hdca_leaf_hda_descendants_cte``). A
``list:paired`` collection nests one level deeper than a flat list —
verify the recursive walk still catches non-matching leaves.
"""
all_bed_response = self.dataset_collection_populator.upload_collection(
history_id,
"list:paired",
elements=[
{
"name": "p0",
"elements": [
{"src": "pasted", "paste_content": "chr1\t1\t10", "name": "forward", "ext": "bed"},
{"src": "pasted", "paste_content": "chr1\t1\t10", "name": "reverse", "ext": "bed"},
],
},
],
name="lp_all_bed",
wait=True,
)
self._assert_status_code_is_ok(all_bed_response)
mixed_response = self.dataset_collection_populator.upload_collection(
history_id,
"list:paired",
elements=[
{
"name": "p0",
"elements": [
{"src": "pasted", "paste_content": "chr1\t1\t10", "name": "forward", "ext": "bed"},
{"src": "pasted", "paste_content": "abc", "name": "reverse", "ext": "txt"},
],
},
],
name="lp_mixed",
wait=True,
)
self._assert_status_code_is_ok(mixed_response)
response = self._get(
f"histories/{history_id}/contents?v=dev"
"&q=history_content_type-eq&qv=dataset_collection"
"&q=extension-eq&qv=bed"
).json()
names = {c["name"] for c in response}
assert names == {"lp_all_bed"}, names
@skip_without_tool("cat1")
def test_cannot_run_tools_on_immutable_histories(self, history_id):
create_response = self.dataset_collection_populator.create_pair_in_history(
+41 -11
View File
@@ -450,24 +450,54 @@ class TestToolsApi(ApiTestCase, TestsTools):
"""``options_pagination[...].search`` filters HDAs by name (ilike)
before pagination, so users can find datasets outside the default page
window by typing into the dropdown."""
sentinel = "unique-zebra-xyz"
with self.dataset_populator.test_history() as history_id:
# Distinct names so we can assert exact match counts.
hdas = self.dataset_populator.fetch_hdas(
# 60 distinct "Sample N" HDAs plus one sentinel name that shares
# no substring with the others. Searching for the sentinel proves
# ilike is actually filtering (rather than passing everything
# through).
self.dataset_populator.fetch_hdas(
history_id,
[{"src": "pasted", "paste_content": "x", "name": f"Sample {i}"} for i in range(60)],
)
target_name = hdas[7]["name"] # e.g., "Sample 7"
payload = {
"history_id": history_id,
"options_pagination": {"input1": {"hda": {"search": target_name}}},
}
response = self.dataset_populator._post("tools/cat1/build", data=payload, json=True)
self.dataset_populator.fetch_hdas(
history_id,
[{"src": "pasted", "paste_content": "x", "name": sentinel}],
)
# Sentinel: exactly one match, with the expected name.
response = self.dataset_populator._post(
"tools/cat1/build",
data={
"history_id": history_id,
"options_pagination": {"input1": {"hda": {"search": "zebra"}}},
},
json=True,
)
response.raise_for_status()
build = response.json()
input_param = next(i for i in build["inputs"] if i["name"] == "input1")
returned_names = [entry["name"] for entry in input_param["options"]["hda"]]
assert all(target_name in n for n in returned_names), returned_names
assert input_param["options_meta"]["hda"]["total_estimate"] == len(returned_names)
returned = input_param["options"]["hda"]
assert len(returned) == 1, returned
assert returned[0]["name"] == sentinel
assert input_param["options_meta"]["hda"]["total_estimate"] == 1
# Broad term ("Sample") matches all 60 Sample HDAs — exceeds the
# default 50-page so we get a partial page with has_more=True.
response = self.dataset_populator._post(
"tools/cat1/build",
data={
"history_id": history_id,
"options_pagination": {"input1": {"hda": {"search": "Sample"}}},
},
json=True,
)
response.raise_for_status()
build = response.json()
input_param = next(i for i in build["inputs"] if i["name"] == "input1")
assert len(input_param["options"]["hda"]) == 50
assert input_param["options_meta"]["hda"]["has_more"] is True
assert all("Sample" in entry["name"] for entry in input_param["options"]["hda"])
@skip_without_tool("cat1")
def test_build_data_options_search_by_hid(self):
@@ -129,6 +129,13 @@ class TestWorkflowRun(SeleniumTestCase, UsesHistoryItemAssertions, RunsWorkflows
and asserts the dropdown narrows to the backend-matched options."""
history_id = self.current_history_id()
self.dataset_populator.fetch_hdas(history_id, [{"src": "pasted", "paste_content": "x"}] * 60)
# Sentinel-named HDA so we can prove options are *actually rendering*
# (a vacuous pass — empty dropdown — would clear the ``<= 50`` upper bound).
legacy_sentinel = "unique-pagination-sentinel"
self.dataset_populator.fetch_hdas(
history_id,
[{"src": "pasted", "paste_content": "y", "name": legacy_sentinel}],
)
self.home()
# A single cat1 step with no workflow-level inputs — the step's
# ``input1`` is unconnected, so it renders as a dropdown the user
@@ -144,6 +151,11 @@ class TestWorkflowRun(SeleniumTestCase, UsesHistoryItemAssertions, RunsWorkflows
self.sleep_for(self.wait_types.UX_RENDER)
baseline_options = select_field.find_elements(By.CSS_SELECTOR, "[role='option']")
assert len(baseline_options) <= 50, f"Expected default page to cap at 50 options, got {len(baseline_options)}"
# Positive lower-bound: the sentinel HDA is newest (hid=61) so it must
# appear in the first page of (newest-first) options. Without this the
# ``<= 50`` upper bound passes vacuously on an empty dropdown.
baseline_labels = [opt.text for opt in baseline_options]
assert any(legacy_sentinel in label for label in baseline_labels), baseline_labels
search_input = select_field.find_element(By.CSS_SELECTOR, "input.multiselect__input")
search_input.send_keys("1")
# Wait past the FormSelect search debounce (300 ms) plus the network
@@ -171,6 +183,12 @@ class TestWorkflowRun(SeleniumTestCase, UsesHistoryItemAssertions, RunsWorkflows
component doesn't yet wire interactive load-more."""
history_id = self.current_history_id()
self.dataset_populator.fetch_hdas(history_id, [{"src": "pasted", "paste_content": "x"}] * 60)
# Sentinel for positive lower-bound (see legacy-form test).
simplified_sentinel = "unique-simplified-sentinel"
self.dataset_populator.fetch_hdas(
history_id,
[{"src": "pasted", "paste_content": "y", "name": simplified_sentinel}],
)
self.home()
self.workflow_run_open_workflow(WORKFLOW_SIMPLE_CAT_TWICE)
# Ensure the legacy/expanded form is fully rendered before reaching for
@@ -196,6 +214,11 @@ class TestWorkflowRun(SeleniumTestCase, UsesHistoryItemAssertions, RunsWorkflows
assert (
len(options) <= 50
), f"Simplified form dropdown must respect the 50-per-page cap; got {len(options)} options"
# Positive lower-bound: the sentinel HDA is newest (hid=61) so it must
# appear in the first page of options. Without this the ``<= 50`` upper
# bound passes vacuously on an empty dropdown.
labels = [opt.text for opt in options]
assert any(simplified_sentinel in label for label in labels), labels
@selenium_only("Not yet migrated to support Playwright backend")
@selenium_test