diff --git a/client/src/components/History/Content/ContentItem.vue b/client/src/components/History/Content/ContentItem.vue
index b57282a2e36..34171c74de5 100644
--- a/client/src/components/History/Content/ContentItem.vue
+++ b/client/src/components/History/Content/ContentItem.vue
@@ -14,27 +14,34 @@
-
+
-
-
+
-
-
-
-
+
+
+
+
@@ -86,7 +93,7 @@
v-if="expandDataset"
:dataset="item"
:writable="writable"
- :show-highlight="isHistoryItem"
+ :show-highlight="isHistoryItem && filterable"
:item-urls="itemUrls"
@edit="onEdit"
@toggleHighlights="toggleHighlights" />
@@ -104,11 +111,10 @@ import { updateContentFields } from "components/History/model/queries";
import { JobStateSummary } from "./Collection/JobStateSummary";
import { library } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
-import { faArrowCircleUp, faMinusCircle, faCheckCircle } from "@fortawesome/free-solid-svg-icons";
+import { faArrowCircleUp, faArrowCircleDown, faCheckCircle } from "@fortawesome/free-solid-svg-icons";
import { useEntryPointStore } from "stores/entryPointStore";
-library.add(faArrowCircleUp, faMinusCircle, faCheckCircle);
-
+library.add(faArrowCircleUp, faArrowCircleDown, faCheckCircle);
export default {
components: {
CollectionDescription,
diff --git a/client/src/components/History/Content/model/highlights.js b/client/src/components/History/Content/model/highlights.js
deleted file mode 100644
index 7ae21b5905a..00000000000
--- a/client/src/components/History/Content/model/highlights.js
+++ /dev/null
@@ -1,67 +0,0 @@
-/**
- * Specifies highlighted items in the history listing. The `highlight` property is passed to
- * the content item component and can be used to modify its appearance.
- * TO DO: Consider case where parameter history is different and hence inputs cannot be seen
- * in the current panel.
- */
-import axios from "axios";
-import { prependPath } from "utils/redirect";
-import { deepeach } from "utils/utils";
-import { LastQueue } from "utils/promise-queue";
-
-// add promise queue
-const lastQueue = new LastQueue(300);
-
-/** Local cache for parameter requests */
-const paramStash = new Map();
-
-/** Performs request to obtain dataset parameters */
-async function getDatasetParameters(datasetId, jobId) {
- if (!paramStash.has(datasetId)) {
- const url = jobId
- ? `api/jobs/${jobId}/parameters_display`
- : `api/datasets/${datasetId}/parameters_display?hda_ldda=hda`;
- const { data } = await lastQueue.enqueue(axios.get, prependPath(url));
- paramStash.set(datasetId, data);
- }
- return paramStash.get(datasetId);
-}
-
-/** Returns item key */
-function getKey(details) {
- if (details.id && details.src) {
- const historyContentType = details.src == "hda" ? "dataset" : "dataset_collection";
- return `${details.id}-${historyContentType}`;
- }
- return null;
-}
-
-/** Returns highlighting details */
-export async function getHighlights(item, itemKey) {
- const highlights = {};
- const { outputs, parameters } = await getDatasetParameters(item.id, item.job_source_id);
- deepeach(parameters, (details) => {
- const key = getKey(details);
- if (key) {
- highlights[key] = "input";
- }
- });
- deepeach(outputs, (details) => {
- const key = getKey(details);
- if (key) {
- // some other item created this item (e.g.: inheritance)
- if (key != itemKey) {
- highlights[itemKey] = "output";
- highlights[key] = "input";
- } else {
- highlights[key] = "output";
- }
- }
- });
- // highlights only has item itself as an output (i.e.: no inputs)
- if (highlights[itemKey] === "output" && Object.keys(highlights).length == 1) {
- highlights[itemKey] = "noInputs";
- }
- // TO DO: Consider case where a job created multiple items (all highlights are outputs)
- return highlights;
-}
diff --git a/client/src/components/History/CurrentHistory/HistoryFilters/HistoryFilters.vue b/client/src/components/History/CurrentHistory/HistoryFilters/HistoryFilters.vue
index 5d4a015db72..12a81ffe24b 100644
--- a/client/src/components/History/CurrentHistory/HistoryFilters/HistoryFilters.vue
+++ b/client/src/components/History/CurrentHistory/HistoryFilters/HistoryFilters.vue
@@ -46,6 +46,8 @@
Filter by state:
+ Filter by related to item index:
+
Filter by item index:
diff --git a/client/src/components/History/CurrentHistory/HistoryPanel.vue b/client/src/components/History/CurrentHistory/HistoryPanel.vue
index 187c6f945d1..cb453905505 100644
--- a/client/src/components/History/CurrentHistory/HistoryPanel.vue
+++ b/client/src/components/History/CurrentHistory/HistoryPanel.vue
@@ -144,7 +144,6 @@ import { useHistoryItemsStore } from "stores/history/historyItemsStore";
import LoadingSpan from "components/LoadingSpan";
import ContentItem from "components/History/Content/ContentItem";
import { deleteContent, updateContentFields } from "components/History/model/queries";
-import { getHighlights } from "components/History/Content/model/highlights";
import ExpandedItems from "components/History/Content/ExpandedItems";
import SelectedItems from "components/History/Content/SelectedItems";
import ListingLayout from "components/History/Layout/ListingLayout";
@@ -193,7 +192,6 @@ export default {
return {
error: null,
filterText: "",
- highlights: {},
highlightsKey: null,
invisible: {},
loading: false,
@@ -255,13 +253,11 @@ export default {
queryKey() {
this.invisible = {};
this.offset = 0;
- this.resetHighlights();
this.loadHistoryItems();
},
historyId(newVal, oldVal) {
if (newVal !== oldVal) {
this.operationRunning = null;
- this.resetHighlights();
}
},
filter(newVal) {
@@ -281,10 +277,19 @@ export default {
...vuexMapActions("history", ["loadHistoryById"]),
...mapActions(useHistoryItemsStore, ["fetchHistoryItems"]),
getHighlight(item) {
- return this.highlights[this.getItemKey(item)];
- },
- getItemKey(item) {
- return `${item.id}-${item.history_content_type}`;
+ if (this.filterText.includes("related:" + item.hid)) {
+ this.highlightsKey = item.hid;
+ return "active";
+ } else if (this.filterText.includes("related:") && this.highlightsKey) {
+ if (item.hid > this.highlightsKey) {
+ return "output";
+ } else {
+ return "input";
+ }
+ } else {
+ this.highlightsKey = null;
+ return null;
+ }
},
hasMatches(items) {
return !!items && items.length > 0;
@@ -344,19 +349,13 @@ export default {
console.debug("HistoryPanel - Operation error.", error);
this.operationError = error;
},
- async toggleHighlights(item) {
- const key = this.getItemKey(item);
- if (this.highlightsKey != key) {
- this.highlightsKey = key;
- this.highlights = await getHighlights(item, key);
+ toggleHighlights(item) {
+ if (this.filterText == "related:" + item.hid) {
+ this.filterText = "";
} else {
- this.resetHighlights();
+ this.filterText = "related:" + item.hid;
}
},
- resetHighlights() {
- this.highlights = {};
- this.highlightsKey = null;
- },
onDragEnter(e) {
this.dragTarget = e.target;
this.showDropZone = true;
diff --git a/client/src/components/History/HistoryFilters.js b/client/src/components/History/HistoryFilters.js
index f61d53f5883..e8d9dd9bb8e 100644
--- a/client/src/components/History/HistoryFilters.js
+++ b/client/src/components/History/HistoryFilters.js
@@ -9,6 +9,7 @@ export const validFilters = {
hid_gt: compare("hid", "gt"),
hid_le: compare("hid", "le"),
hid_lt: compare("hid", "lt"),
+ related: equals("related"),
tag: contains("tags", "tag", expandNameTag),
visible: equals("visible", "visible", toBool),
deleted: equals("deleted", "deleted", toBool),
diff --git a/client/src/stores/history/historyItemsStore.js b/client/src/stores/history/historyItemsStore.js
index ea2f282193d..7a0563dc297 100644
--- a/client/src/stores/history/historyItemsStore.js
+++ b/client/src/stores/history/historyItemsStore.js
@@ -4,7 +4,7 @@
*/
import { defineStore } from "pinia";
-
+import Vue from "vue";
import { reverse } from "lodash";
import { LastQueue } from "utils/promise-queue";
import { urlData } from "utils/url";
@@ -21,13 +21,15 @@ export const useHistoryItemsStore = defineStore("historyItemsStore", {
latestCreateTime: new Date(),
totalMatchesCount: undefined,
lastCheckedTime: new Date(),
+ relatedItems: {},
isWatching: false,
}),
getters: {
getHistoryItems: (state) => {
return (historyId, filterText) => {
const itemArray = state.items[historyId] || [];
- const filters = HistoryFilters.getFilters(filterText);
+ const filters = HistoryFilters.getFilters(filterText).filter((filter) => !filter.includes("related"));
+ const relatedHid = HistoryFilters.getFilterValue(filterText, "related");
const filtered = itemArray.filter((item) => {
if (!item) {
return false;
@@ -35,6 +37,10 @@ export const useHistoryItemsStore = defineStore("historyItemsStore", {
if (!HistoryFilters.testFilters(filters, item)) {
return false;
}
+ const relationKey = `${historyId}-${relatedHid}-${item.hid}`;
+ if (relatedHid && !state.relatedItems[relationKey]) {
+ return false;
+ }
return true;
});
return reverse(filtered);
@@ -63,11 +69,12 @@ export const useHistoryItemsStore = defineStore("historyItemsStore", {
const stats = data.stats;
this.totalMatchesCount = stats.total_matches;
const payload = data.contents;
- this.saveHistoryItems(historyId, payload);
+ const relatedHid = HistoryFilters.getFilterValue(filterText, "related");
+ this.saveHistoryItems(historyId, payload, relatedHid);
});
},
// Setters
- saveHistoryItems(historyId, payload) {
+ saveHistoryItems(historyId, payload, relatedHid = null) {
this.$patch((state) => {
// merges incoming payload into existing state
mergeArray(historyId, payload, state.items, state.itemKey);
@@ -79,6 +86,10 @@ export const useHistoryItemsStore = defineStore("historyItemsStore", {
state.latestCreateTime = itemCreateTime;
}
}
+ if (relatedHid) {
+ const relationKey = `${historyId}-${relatedHid}-${item.hid}`;
+ Vue.set(state.relatedItems, relationKey, true);
+ }
});
});
},
diff --git a/client/src/utils/filtering.test.js b/client/src/utils/filtering.test.js
index b7f877a9ab0..da88b73a243 100644
--- a/client/src/utils/filtering.test.js
+++ b/client/src/utils/filtering.test.js
@@ -29,12 +29,23 @@ describe("filtering", () => {
expect(queryDict["name-contains"]).toBe("name of item");
});
test("parse check filter", () => {
+ expect(HistoryFilters.checkFilter(filterTexts[0], "name", "name of item")).toBe(true);
expect(HistoryFilters.checkFilter(filterTexts[0], "tag", "first")).toBe(true);
expect(HistoryFilters.checkFilter(filterTexts[0], "tag", "second")).toBe(false);
expect(HistoryFilters.checkFilter(filterTexts[0], "deleted", "false")).toBe(true);
expect(HistoryFilters.checkFilter(filterTexts[0], "visible", true)).toBe(true);
expect(HistoryFilters.checkFilter(filterTexts[0], "visible", "false")).toBe(false);
});
+ test("parse get filter value", () => {
+ expect(HistoryFilters.getFilterValue(filterTexts[0], "name")).toBe("name of item");
+ expect(HistoryFilters.getFilterValue(filterTexts[0], "hid", "gt")).toBe("10");
+ expect(HistoryFilters.getFilterValue(filterTexts[0], "hid", "lt")).toBe("100");
+ expect(HistoryFilters.getFilterValue(filterTexts[0], "tag")).toBe("first");
+ expect(HistoryFilters.getFilterValue(filterTexts[0], "deleted")).toBe("False");
+ expect(HistoryFilters.getFilterValue(filterTexts[0], "visible")).toBe("TRUE");
+ expect(HistoryFilters.getFilterValue(filterTexts[1], "hid", "gt")).toBe("10");
+ expect(HistoryFilters.getFilterValue(filterTexts[1], "create_time", "gt")).toBe("2021-01-01");
+ });
test("parse filter text as entries", () => {
filterTexts.forEach((filterText) => {
const filters = HistoryFilters.getFilters(filterText);
diff --git a/client/src/utils/filtering.ts b/client/src/utils/filtering.ts
index 616bd3a3858..5ea02047fb8 100644
--- a/client/src/utils/filtering.ts
+++ b/client/src/utils/filtering.ts
@@ -18,6 +18,14 @@ const defaultValidAliases = [
["<", "_lt"],
];
+const operatorForAlias: Record = {
+ lt: "<",
+ le: "<=",
+ ge: ">=",
+ gt: ">",
+ eq: ":",
+};
+
/** Converts user input to backend compatible date
* @param {string} value
* @returns {Number} seconds since epoch
@@ -61,6 +69,14 @@ export function expandNameTag(value: string | object): string {
return toLower(value);
}
+/** Converts string alias to string operator, e.g.: 'gt' to '>'
+ * @param alias
+ * @returns Arithmetic operator, e.g.: '>'
+ * */
+export function getOperatorForAlias(alias: string): string {
+ return operatorForAlias[alias];
+}
+
type HandlerReturn = {
attribute: string;
converter?: Converter;
@@ -323,12 +339,28 @@ export default class Filtering {
* @returns {Boolean} True if the filter is set to the given value
* */
checkFilter(filterText: string, filterName: string, filterValue: T): boolean {
- const re = new RegExp(`${filterName}:(\\S+)`);
- const reMatch = re.exec(filterText);
- const testValue = reMatch ? reMatch[1] : this.defaultFilters[filterName];
+ const testValue = this.getFilterValue(filterText, filterName);
return toLowerNoQuotes(testValue) === toLowerNoQuotes(filterValue);
}
+ /** Get the value of a particular filter from filterText.
+ * @param filterText Raw filter text string
+ * @param filterName Filter key to check
+ * @param [alias="eq"] String alias for filter operator, e.g.:"lt"
+ * @returns The filterValue for the filter
+ * */
+ getFilterValue(filterText: string, filterName: string, alias = "eq"): string | boolean {
+ const op = getOperatorForAlias(alias);
+ const reString = `${filterName}(?:${op}|[-|_]${alias}:)(?:'([^']*[^\\s']*)'|(\\S+))`;
+ const re = new RegExp(reString);
+ const reMatch = re.exec(filterText);
+ let filterVal = null;
+ if (reMatch) {
+ filterVal = reMatch[1] || reMatch[2];
+ }
+ return filterVal || this.defaultFilters[filterName];
+ }
+
/** Test if an item passes all filters.
* @param {Object} filters Parsed in key-value pairs from getFilters()
* @param {Object} item Item to test against the filters
diff --git a/lib/galaxy/managers/history_contents.py b/lib/galaxy/managers/history_contents.py
index 34131c07af7..055a8b94687 100644
--- a/lib/galaxy/managers/history_contents.py
+++ b/lib/galaxy/managers/history_contents.py
@@ -2,6 +2,7 @@
Heterogenous lists/contents are difficult to query properly since unions are
not easily made.
"""
+import json
import logging
from typing import (
Any,
@@ -41,6 +42,8 @@ from galaxy.managers import (
taggable,
tools,
)
+from galaxy.managers.job_connections import JobConnectionsManager
+from galaxy.schema import ValueFilterQueryParams
from galaxy.structured_app import MinimalManagerApp
from .base import (
parse_bool,
@@ -525,6 +528,30 @@ class HistoryContentsFilters(
# surprisingly (but ominously), this works for both content classes in the union that's filtered
model_class = model.HistoryDatasetAssociation
+ def parse_query_filters_with_relations(self, query_filters: ValueFilterQueryParams, history_id):
+ """Parse query filters but consider case where related filter is included."""
+ if query_filters.q and query_filters.qv and "related-eq" in query_filters.q:
+ qv_index = query_filters.q.index("related-eq")
+ qv_hid = query_filters.qv[qv_index]
+
+ # Make new q and qv excluding related filter
+ new_q = [x for i, x in enumerate(query_filters.q) if i != qv_index]
+ new_qv = [x for i, x in enumerate(query_filters.qv) if i != qv_index]
+
+ # Get list of related item hids from job_connections manager
+ job_connections_manager = JobConnectionsManager(self.app.model.session)
+ related = job_connections_manager.get_related_hids(history_id, qv_hid)
+
+ # Make new query_filters with updated list of related hids for given hid
+ new_q.append("related-eq")
+ new_qv.append(json.dumps(related))
+ query_filters_with_relations = ValueFilterQueryParams(
+ q=new_q,
+ qv=new_qv,
+ )
+ return super().parse_query_filters(query_filters_with_relations)
+ return super().parse_query_filters(query_filters)
+
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
@@ -538,6 +565,9 @@ class HistoryContentsFilters(
return sql.column("history_content_type") == val
raise_filter_err(attr, op, val, "bad op in filter")
+ if attr == "related" and op == "eq":
+ return sql.column("hid").in_(json.loads(val))
+
if attr == "type_id":
if op == "eq":
return sql.column("type_id") == val
@@ -595,6 +625,7 @@ class HistoryContentsFilters(
self.orm_filter_parsers.update(
{
"history_content_type": {"op": ("eq")},
+ "related": {"op": ("eq")},
"type_id": {"op": ("eq", "in"), "val": self.parse_type_id_list},
"hid": {"op": ("eq", "ge", "le", "gt", "lt"), "val": int},
# TODO: needs a different val parser - but no way to add to the above
diff --git a/lib/galaxy/managers/job_connections.py b/lib/galaxy/managers/job_connections.py
new file mode 100644
index 00000000000..c52480c93d2
--- /dev/null
+++ b/lib/galaxy/managers/job_connections.py
@@ -0,0 +1,182 @@
+from sqlalchemy import (
+ literal,
+ union,
+)
+from sqlalchemy.sql import (
+ expression,
+ select,
+)
+
+from galaxy import model
+from galaxy.managers.base import get_class
+from galaxy.model.scoped_session import galaxy_scoped_session
+
+
+class JobConnectionsManager:
+ """Get connections graph of inputs and outputs for given item"""
+
+ def __init__(self, sa_session: galaxy_scoped_session):
+ self.sa_session = sa_session
+
+ def get_connections_graph(self, id, src):
+ """Get connections graph of inputs and outputs for given item id"""
+ if src == "HistoryDatasetAssociation":
+ output_selects = self.outputs_derived_from_input_hda(id)
+ input_selects = self.inputs_for_hda(id)
+ elif src == "HistoryDatasetCollectionAssociation":
+ output_selects = self.outputs_derived_from_input_hdca(id)
+ input_selects = self.inputs_for_hdca(id)
+ else:
+ raise Exception(f"Invalid item type {src}")
+ # Execute selects and return graph of inputs and outputs
+ result = {}
+ result["outputs"] = self._get_union_results(*output_selects)
+ result["inputs"] = self._get_union_results(*input_selects)
+ return result
+
+ def get_related_hids(self, history_id, hid):
+ """Get connections graph of inputs and outputs for given item hid from the given history_id"""
+ # Get id(s) and src(s) for the given hid
+ items_by_hid = self.sa_session.execute(
+ select(model.HistoryDatasetAssociation.id, expression.literal("HistoryDatasetAssociation"))
+ .filter_by(history_id=history_id, hid=hid)
+ .union(
+ select(
+ model.HistoryDatasetCollectionAssociation.id,
+ expression.literal("HistoryDatasetCollectionAssociation"),
+ ).filter_by(history_id=history_id, hid=hid)
+ )
+ ).all()
+ result = [hid]
+ for item_data in items_by_hid:
+ graph = self.get_connections_graph(id=item_data[0], src=item_data[1])
+ # Add found related items' hids to result list
+ for val in graph["outputs"] + graph["inputs"]:
+ item_class = get_class(val["src"])
+ item = self.sa_session.query(item_class).get(val["id"])
+ result.append(item.hid)
+ return result
+
+ def _get_union_results(self, *selects):
+ result = []
+ for row in self.sa_session.execute(union(*selects)).all():
+ result.append({"src": row.src, "id": row.id})
+ return result
+
+ def outputs_derived_from_input_hda(self, input_hda_id):
+ hda_select = (
+ select(
+ [
+ literal("HistoryDatasetAssociation").label("src"),
+ model.JobToOutputDatasetAssociation.dataset_id.label("id"),
+ ]
+ )
+ .join(
+ model.JobToInputDatasetAssociation,
+ model.JobToInputDatasetAssociation.job_id == model.JobToOutputDatasetAssociation.job_id,
+ )
+ .where(model.JobToInputDatasetAssociation.dataset_id == input_hda_id)
+ )
+ hdca_select = (
+ select(
+ [
+ literal("HistoryDatasetCollectionAssociation").label("src"),
+ model.JobToOutputDatasetCollectionAssociation.dataset_collection_id.label("id"),
+ ]
+ )
+ .join(
+ model.JobToInputDatasetAssociation,
+ model.JobToInputDatasetAssociation.job_id == model.JobToOutputDatasetCollectionAssociation.job_id,
+ )
+ .where(model.JobToInputDatasetAssociation.dataset_id == input_hda_id)
+ )
+ return hda_select, hdca_select
+
+ def outputs_derived_from_input_hdca(self, input_hdca_id):
+ hda_select = (
+ select(
+ [
+ literal("HistoryDatasetAssociation").label("src"),
+ model.JobToOutputDatasetAssociation.dataset_id.label("id"),
+ ]
+ )
+ .join(
+ model.JobToInputDatasetCollectionAssociation,
+ model.JobToInputDatasetCollectionAssociation.job_id == model.JobToOutputDatasetAssociation.job_id,
+ )
+ .where(model.JobToInputDatasetCollectionAssociation.dataset_collection_id == input_hdca_id)
+ )
+ hdca_select = (
+ select(
+ [
+ literal("HistoryDatasetCollectionAssociation").label("src"),
+ model.JobToOutputDatasetCollectionAssociation.dataset_collection_id.label("id"),
+ ]
+ )
+ .join(
+ model.JobToInputDatasetCollectionAssociation,
+ model.JobToInputDatasetCollectionAssociation.job_id
+ == model.JobToOutputDatasetCollectionAssociation.job_id,
+ )
+ .where(model.JobToInputDatasetCollectionAssociation.dataset_collection_id == input_hdca_id)
+ )
+ return hda_select, hdca_select
+
+ def inputs_for_hda(self, input_hda_id):
+ input_hdas = (
+ select(
+ [
+ literal("HistoryDatasetAssociation").label("src"),
+ model.JobToInputDatasetAssociation.dataset_id.label("id"),
+ ]
+ )
+ .join(
+ model.JobToOutputDatasetAssociation,
+ model.JobToOutputDatasetAssociation.job_id == model.JobToInputDatasetAssociation.job_id,
+ )
+ .where(model.JobToOutputDatasetAssociation.dataset_id == input_hda_id)
+ )
+ input_hdcas = (
+ select(
+ [
+ literal("HistoryDatasetCollectionAssociation").label("src"),
+ model.JobToInputDatasetCollectionAssociation.dataset_collection_id.label("id"),
+ ]
+ )
+ .join(
+ model.JobToOutputDatasetAssociation,
+ model.JobToOutputDatasetAssociation.job_id == model.JobToInputDatasetCollectionAssociation.job_id,
+ )
+ .where(model.JobToOutputDatasetAssociation.dataset_id == input_hda_id)
+ )
+ return input_hdas, input_hdcas
+
+ def inputs_for_hdca(self, input_hdca_id):
+ input_hdas = (
+ select(
+ [
+ literal("HistoryDatasetAssociation").label("src"),
+ model.JobToInputDatasetAssociation.dataset_id.label("id"),
+ ]
+ )
+ .join(
+ model.JobToOutputDatasetCollectionAssociation,
+ model.JobToOutputDatasetCollectionAssociation.job_id == model.JobToInputDatasetAssociation.job_id,
+ )
+ .where(model.JobToOutputDatasetCollectionAssociation.dataset_collection_id == input_hdca_id)
+ )
+ input_hdcas = (
+ select(
+ [
+ literal("HistoryDatasetCollectionAssociation").label("src"),
+ model.JobToInputDatasetCollectionAssociation.dataset_collection_id.label("id"),
+ ]
+ )
+ .join(
+ model.JobToOutputDatasetCollectionAssociation,
+ model.JobToOutputDatasetCollectionAssociation.job_id
+ == model.JobToInputDatasetCollectionAssociation.job_id,
+ )
+ .where(model.JobToOutputDatasetCollectionAssociation.dataset_collection_id == input_hdca_id)
+ )
+ return input_hdas, input_hdcas
diff --git a/lib/galaxy/webapps/galaxy/services/history_contents.py b/lib/galaxy/webapps/galaxy/services/history_contents.py
index dbbf09b269e..0c72c4b497c 100644
--- a/lib/galaxy/webapps/galaxy/services/history_contents.py
+++ b/lib/galaxy/webapps/galaxy/services/history_contents.py
@@ -939,7 +939,8 @@ class HistoriesContentsService(ServiceBase, ServesExportStores, ConsumesModelSto
Allows additional filtering of contents and custom serialization.
"""
history = self._get_history(trans, history_id)
- filters = self.history_contents_filters.parse_query_filters(filter_query_params)
+
+ filters = self.history_contents_filters.parse_query_filters_with_relations(filter_query_params, history_id)
stats_requested = accept == HistoryContentsWithStatsResult.__accept_type__
if stats_requested and self.history_contents_filters.contains_non_orm_filter(filters):
diff --git a/test/unit/app/managers/test_JobConnectionsManager.py b/test/unit/app/managers/test_JobConnectionsManager.py
new file mode 100644
index 00000000000..844b4dbdcdf
--- /dev/null
+++ b/test/unit/app/managers/test_JobConnectionsManager.py
@@ -0,0 +1,126 @@
+import pytest
+from sqlalchemy import union
+
+from galaxy.managers.job_connections import JobConnectionsManager
+from galaxy.model import (
+ HistoryDatasetAssociation,
+ HistoryDatasetCollectionAssociation,
+ Job,
+)
+from galaxy.model.scoped_session import galaxy_scoped_session
+from galaxy.model.unittest_utils import GalaxyDataTestApp
+
+
+@pytest.fixture
+def sa_session():
+ return GalaxyDataTestApp().model.session
+
+
+@pytest.fixture
+def job_connections_manager(sa_session) -> JobConnectionsManager:
+ gm = JobConnectionsManager(sa_session)
+ return gm
+
+
+# =============================================================================
+def setup_connected_dataset(sa_session: galaxy_scoped_session):
+ center_hda = HistoryDatasetAssociation(sa_session=sa_session)
+ input_hda = HistoryDatasetAssociation(sa_session=sa_session)
+ input_hdca = HistoryDatasetCollectionAssociation()
+ output_hda = HistoryDatasetAssociation(sa_session=sa_session)
+ output_hdca = HistoryDatasetCollectionAssociation()
+ input_job = Job()
+ output_job = Job()
+ input_job.add_output_dataset("output_hda", center_hda)
+ input_job.add_input_dataset("input_hda", input_hda)
+ input_job.add_input_dataset_collection("input_hdca", input_hdca)
+ output_job.add_input_dataset("input_hda", center_hda)
+ output_job.add_output_dataset("output_hda", output_hda)
+ output_job.add_output_dataset_collection("output_hdca", output_hdca)
+ sa_session.add_all([center_hda, input_hda, input_hdca, output_hdca, input_job, output_job])
+ sa_session.flush()
+ expected_graph = {
+ "inputs": [
+ {"src": "HistoryDatasetAssociation", "id": input_hda.id},
+ {"src": "HistoryDatasetCollectionAssociation", "id": input_hdca.id},
+ ],
+ "outputs": [
+ {"src": "HistoryDatasetAssociation", "id": output_hda.id},
+ {"src": "HistoryDatasetCollectionAssociation", "id": output_hdca.id},
+ ],
+ }
+ return center_hda, expected_graph
+
+
+def setup_connected_dataset_collection(sa_session: galaxy_scoped_session):
+ center_hdca = HistoryDatasetCollectionAssociation()
+ input_hda1 = HistoryDatasetAssociation(sa_session=sa_session)
+ input_hda2 = HistoryDatasetAssociation(sa_session=sa_session)
+ input_hdca = HistoryDatasetCollectionAssociation()
+ output_hda = HistoryDatasetAssociation(sa_session=sa_session)
+ output_hdca = HistoryDatasetCollectionAssociation()
+ input_job = Job()
+ output_job = Job()
+ input_job.add_output_dataset_collection("output_hdca", center_hdca)
+ input_job.add_input_dataset("input_hda", input_hda1)
+ input_job.add_input_dataset("input_hda", input_hda2)
+ input_job.add_input_dataset_collection("input_hdca", input_hdca)
+ output_job.add_input_dataset_collection("input_hdca", center_hdca)
+ output_job.add_output_dataset("output_hda", output_hda)
+ output_job.add_output_dataset_collection("output_hdca", output_hdca)
+ sa_session.add_all([center_hdca, input_hda1, input_hda2, input_hdca, output_hdca, input_job, output_job])
+ sa_session.flush()
+ expected_graph = {
+ "inputs": [
+ {"src": "HistoryDatasetAssociation", "id": input_hda1.id},
+ {"src": "HistoryDatasetAssociation", "id": input_hda2.id},
+ {"src": "HistoryDatasetCollectionAssociation", "id": input_hdca.id},
+ ],
+ "outputs": [
+ {"src": "HistoryDatasetAssociation", "id": output_hda.id},
+ {"src": "HistoryDatasetCollectionAssociation", "id": output_hdca.id},
+ ],
+ }
+ return center_hdca, expected_graph
+
+
+# =============================================================================
+def test_graph_manager_inputs_for_hda(job_connections_manager: JobConnectionsManager):
+ sa_session = job_connections_manager.sa_session
+ center_hda, expected_graph = setup_connected_dataset(sa_session)
+ s = job_connections_manager.inputs_for_hda(center_hda.id)
+ assert len(sa_session.execute(union(*s)).all()) == 2
+
+
+def test_graph_manager_outputs_for_hda(job_connections_manager: JobConnectionsManager):
+ sa_session = job_connections_manager.sa_session
+ center_hda, expected_graph = setup_connected_dataset(sa_session)
+ s = job_connections_manager.outputs_derived_from_input_hda(center_hda.id)
+ assert len(sa_session.execute(union(*s)).all()) == 2
+
+
+def test_graph_manager_inputs_for_hdca(job_connections_manager: JobConnectionsManager):
+ sa_session = job_connections_manager.sa_session
+ center_hdca, expected_graph = setup_connected_dataset_collection(sa_session)
+ s = job_connections_manager.inputs_for_hdca(center_hdca.id)
+ assert len(sa_session.execute(union(*s)).all()) == 3
+
+
+def test_graph_manager_outputs_for_hdca(job_connections_manager: JobConnectionsManager):
+ sa_session = job_connections_manager.sa_session
+ center_hdca, expected_graph = setup_connected_dataset_collection(sa_session)
+ s = job_connections_manager.outputs_derived_from_input_hdca(center_hdca.id)
+ assert len(sa_session.execute(union(*s)).all()) == 2
+
+
+def test_graph_manager_hda(job_connections_manager: JobConnectionsManager):
+ center_hda, expected_graph = setup_connected_dataset(job_connections_manager.sa_session)
+ assert job_connections_manager.get_connections_graph(center_hda.id, "HistoryDatasetAssociation") == expected_graph
+
+
+def test_graph_manager_hdca(job_connections_manager: JobConnectionsManager):
+ center_hdca, expected_graph = setup_connected_dataset_collection(job_connections_manager.sa_session)
+ assert (
+ job_connections_manager.get_connections_graph(center_hdca.id, "HistoryDatasetCollectionAssociation")
+ == expected_graph
+ )