mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-24 16:30:27 +08:00
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:
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user