Merge pull request #20731 from mvdbeek/hierarchical_display_collection_dataset_states

Hierarchical display collection dataset states
This commit is contained in:
Marius van den Beek
2025-08-13 12:55:14 +02:00
committed by GitHub
24 changed files with 1051 additions and 129 deletions
+10
View File
@@ -113,3 +113,13 @@ export async function fetchDatasetAttributes(datasetId: string) {
export type HistoryContentType = components["schemas"]["HistoryContentType"];
export type HistoryContentSource = components["schemas"]["HistoryContentSource"];
/** Dataset state constants */
// Non-terminal dataset states (dataset is still being processed)
export const NON_TERMINAL_DATASET_STATES = ["new", "upload", "queued", "running", "setting_metadata"];
// Error dataset states (dataset failed processing)
export const ERROR_DATASET_STATES = ["error", "failed_metadata"];
// Terminal dataset states (dataset processing is complete)
export const TERMINAL_DATASET_STATES = ["ok", "empty", "deferred", "discarded", "paused"].concat(ERROR_DATASET_STATES);
+60
View File
@@ -8958,6 +8958,18 @@ export interface components {
* @default []
*/
elements: components["schemas"]["DCESummary"][];
/**
* Elements Datatypes
* @description A set containing all the different element datatypes in the collection.
*/
elements_datatypes: string[];
/**
* Datasets deleted
* @description The number of elements in the collection that are marked as deleted.
*/
elements_deleted: number;
/** @description A dictionary containing counts for each dataset state in the collection. */
elements_states: components["schemas"]["ElementsStatesDict"];
/**
* Dataset Collection ID
* @example 0123456789ABCDEF
@@ -10224,6 +10236,33 @@ export interface components {
* @enum {string}
*/
ElementsFromType: "archive" | "bagit" | "bagit_archive" | "directory";
/** ElementsStatesDict */
ElementsStatesDict: {
/** Deferred */
deferred?: number;
/** Discarded */
discarded?: number;
/** Empty */
empty?: number;
/** Error */
error?: number;
/** Failed Metadata */
failed_metadata?: number;
/** New */
new?: number;
/** Ok */
ok?: number;
/** Paused */
paused?: number;
/** Queued */
queued?: number;
/** Running */
running?: number;
/** Setting Metadata */
setting_metadata?: number;
/** Upload */
upload?: number;
};
/** EmptyFieldParameterValidatorModel */
EmptyFieldParameterValidatorModel: {
/**
@@ -12281,6 +12320,13 @@ export interface components {
* @description A set containing all the different element datatypes in the collection.
*/
elements_datatypes?: string[] | null;
/**
* Datasets deleted
* @description The number of elements in the collection that are marked as deleted.
*/
elements_deleted?: number | null;
/** @description A dictionary containing counts for each dataset state in the collection. */
elements_states?: components["schemas"]["ElementsStatesDict"] | null;
/**
* HID
* @description The index position of this item in the History.
@@ -12427,6 +12473,13 @@ export interface components {
* @description A set containing all the different element datatypes in the collection.
*/
elements_datatypes: string[];
/**
* Datasets deleted
* @description The number of elements in the collection that are marked as deleted.
*/
elements_deleted: number;
/** @description A dictionary containing counts for each dataset state in the collection. */
elements_states: components["schemas"]["ElementsStatesDict"];
/**
* HID
* @description The index position of this item in the History.
@@ -12565,6 +12618,13 @@ export interface components {
* @description A set containing all the different element datatypes in the collection.
*/
elements_datatypes: string[];
/**
* Datasets deleted
* @description The number of elements in the collection that are marked as deleted.
*/
elements_deleted: number;
/** @description A dictionary containing counts for each dataset state in the collection. */
elements_states: components["schemas"]["ElementsStatesDict"];
/**
* HID
* @description The index position of this item in the History.
@@ -22,6 +22,8 @@ const defaultTestHDCA: HDCASummary = {
deleted: false,
visible: true,
elements_datatypes: [],
elements_deleted: 0,
elements_states: {},
history_id: "fake_history_id",
model_class: "HistoryDatasetCollectionAssociation",
populated_state: "ok",
@@ -1,11 +1,14 @@
<script setup lang="ts">
import { BBadge } from "bootstrap-vue";
import { computed } from "vue";
import type { HDCASummary } from "@/api";
import { DatasetStateSummary } from "./DatasetStateSummary";
import { JobStateSummary } from "./JobStateSummary";
import CollectionProgress from "./CollectionProgress.vue";
import DatasetProgress from "./DatasetProgress.vue";
interface Props {
hdca: HDCASummary;
@@ -28,6 +31,10 @@ const jobStateSummary = computed(() => {
return new JobStateSummary(props.hdca);
});
const datasetStateSummary = computed(() => {
return new DatasetStateSummary(props.hdca);
});
const collectionLabel = computed(() => {
const collectionType = props.hdca.collection_type;
const isList = collectionType.startsWith("list");
@@ -89,15 +96,30 @@ function pluralize(word: string) {
<template>
<div>
<span v-if="hdca.collection_type == 'paired_or_unpaired'" class="description mt-1 mb-1">
a <b v-if="isHomogeneous">{{ homogeneousDatatype }}</b> {{ pluralizedItem }}
</span>
<span v-else class="description mt-1 mb-1">
a {{ collectionLabel }} with {{ hdca.element_count || 0
}}<b v-if="isHomogeneous">{{ homogeneousDatatype }}</b> {{ pluralizedItem }}
</span>
<div class="d-flex justify-content-between align-items-start">
<div>
<span v-if="hdca.collection_type == 'paired_or_unpaired'" class="description mt-1 mb-1">
a <b v-if="isHomogeneous">{{ homogeneousDatatype }}</b> {{ pluralizedItem }}
</span>
<span v-else class="description mt-1 mb-1">
a {{ collectionLabel }} with {{ hdca.element_count || 0
}}<b v-if="isHomogeneous">{{ homogeneousDatatype }}</b> {{ pluralizedItem }}
</span>
</div>
<BBadge
v-if="datasetStateSummary.elements_deleted > 0"
variant="warning"
class="ml-2"
:title="`${datasetStateSummary.elements_deleted} deleted ${
datasetStateSummary.elements_deleted === 1 ? 'dataset' : 'datasets'
} in collection`">
<icon icon="trash" /> {{ datasetStateSummary.elements_deleted }} deleted
</BBadge>
</div>
<CollectionProgress v-if="jobStateSummary.size != 0" :summary="jobStateSummary" />
<DatasetProgress v-else-if="datasetStateSummary.datasetCount > 0" :summary="datasetStateSummary" />
</div>
</template>
@@ -0,0 +1,37 @@
<!-- Dataset state progress bar for a collection. -->
<script setup lang="ts">
import type { DatasetStateSummary } from "./DatasetStateSummary";
interface Props {
summary: DatasetStateSummary;
}
defineProps<Props>();
</script>
<template>
<div class="dataset-progress">
<BProgress v-if="!summary.isTerminal" :max="summary.datasetCount">
<BProgressBar
v-if="summary.errorCount"
v-b-tooltip.hover="`${summary.errorCount} Error`"
:value="summary.errorCount"
variant="danger" />
<BProgressBar
v-if="summary.okCount"
v-b-tooltip.hover="`${summary.okCount} OK`"
:value="summary.okCount"
variant="success" />
<BProgressBar
v-if="summary.runningCount"
v-b-tooltip.hover="`${summary.runningCount} Running`"
:value="summary.runningCount"
variant="warning" />
<BProgressBar
v-if="summary.waitingCount"
v-b-tooltip.hover="`${summary.waitingCount} Waiting`"
:value="summary.waitingCount"
variant="secondary" />
</BProgress>
</div>
</template>
@@ -0,0 +1,87 @@
/**
* Read-only class for dataset states in a collection.
* Similar to JobStateSummary but for dataset states.
*/
import { ERROR_DATASET_STATES, NON_TERMINAL_DATASET_STATES } from "@/api/datasets";
interface HDCAWithDatasetStates {
elements_states?: Record<string, number>;
elements_deleted?: number;
populated_state?: string | null;
}
export class DatasetStateSummary {
elements_states: Record<string, number>;
elements_deleted: number;
populated_state: string | null;
constructor(hdca: HDCAWithDatasetStates = {}) {
this.elements_states = hdca.elements_states || {};
this.elements_deleted = hdca.elements_deleted || 0;
this.populated_state = hdca.populated_state || null;
}
get(state: string): number {
return this.elements_states[state] || 0;
}
get datasetCount(): number {
return Object.values(this.elements_states).reduce((sum, count) => sum + count, 0);
}
private getCount(states: string[]): number {
return states.reduce((sum, state) => sum + this.get(state), 0);
}
get okCount(): number {
return this.getCount(["ok", "empty", "deferred"]);
}
get errorCount(): number {
return this.getCount(["error", "failed_metadata"]);
}
get runningCount(): number {
return this.getCount(["running", "setting_metadata"]);
}
get waitingCount(): number {
return this.getCount(["queued", "new", "upload", "paused"]);
}
get deferredCount(): number {
return this.getCount(["deferred", "discarded"]);
}
get isTerminal(): boolean {
return !NON_TERMINAL_DATASET_STATES.some((state) => this.get(state) > 0);
}
get state(): string {
if (this.datasetCount === 0) {
return this.populated_state || "new";
}
if (this.errorCount > 0) {
return "error";
}
if (this.runningCount > 0) {
return "running";
}
if (this.waitingCount > 0) {
return "queued";
}
if (this.deferredCount > 0) {
return "deferred";
}
if (this.isTerminal) {
return "ok";
}
return "new";
}
}
@@ -20,7 +20,7 @@ import { useEntryPointStore } from "@/stores/entryPointStore";
import { useEventStore } from "@/stores/eventStore";
import { clearDrag } from "@/utils/setDrag";
import { getContentItemState, type StateMap, STATES } from "./model/states";
import { getContentItemState, type State, STATES } from "./model/states";
import type { RouterPushOptions } from "./router-push-options";
import CollectionDescription from "./Collection/CollectionDescription.vue";
@@ -97,7 +97,7 @@ const itemIsRunningInteractiveTool = computed(() => {
});
const contentId = computed(() => {
return `dataset-${props.item.id}`;
return isCollection.value ? `collection-${props.item.id}` : `dataset-${props.item.id}`;
});
const contentCls = computed(() => {
@@ -133,7 +133,7 @@ const hasStateIcon = computed(() => {
return contentState.value && contentState.value.icon;
});
const state = computed<keyof StateMap>(() => {
const state = computed<State>(() => {
if (props.isPlaceholder) {
return "placeholder";
}
@@ -159,7 +159,7 @@ const tagsDisabled = computed(() => {
});
const isCollection = computed(() => {
return "collection_type" in props.item;
return "collection_type" in props.item || props.item.element_type === "dataset_collection";
});
const itemUrls = computed<ItemUrls>(() => {
@@ -1,9 +1,28 @@
import { isHDCA } from "@/api";
import type { DCESummary, HDADetailed, HDCADetailed } from "@/api";
import { ERROR_DATASET_STATES, NON_TERMINAL_DATASET_STATES } from "@/api/datasets";
import type { HistoryContentsResult } from "@/api/histories";
import type { components } from "@/api/schema";
import { DatasetStateSummary } from "../Collection/DatasetStateSummary";
type HistoryContentItem = HistoryContentsResult[number];
function isHDCAItem(item: HistoryContentItem | HDADetailed | HDCADetailed | DCESummary): boolean {
return (
item &&
typeof item === "object" &&
"history_content_type" in item &&
item.history_content_type === "dataset_collection"
);
}
function isDCEWithCollection(item: HistoryContentItem | HDADetailed | HDCADetailed | DCESummary): boolean {
return item && typeof item === "object" && "collection_type" in item && item.collection_type !== undefined;
}
type DatasetState = components["schemas"]["DatasetState"];
// The 'failed' state is for the collection job state summary, not a dataset state.
type State =
export type State =
| DatasetState
| "failed"
| "placeholder"
@@ -166,24 +185,51 @@ export const HIERARCHICAL_COLLECTION_JOB_STATES = [
"new",
] as const;
export function getContentItemState(item: any) {
if (isHDCA(item)) {
if (item.populated_state === "failed") {
/** Similar hierarchy for dataset states, ordered from highest to lowest priority. */
export const HIERARCHICAL_COLLECTION_DATASET_STATES = [
...(ERROR_DATASET_STATES as readonly DatasetState[]),
...(NON_TERMINAL_DATASET_STATES as readonly DatasetState[]),
"deferred" as const,
"discarded" as const,
] as const satisfies readonly DatasetState[];
export function getContentItemState(item: HistoryContentItem | HDADetailed | HDCADetailed): State {
if (isHDCAItem(item) || isDCEWithCollection(item)) {
if ("populated_state" in item && item.populated_state === "failed") {
return "failed_populated_state";
}
if (item.populated_state === "new") {
if ("populated_state" in item && item.populated_state === "new") {
return "new_populated_state";
}
if (item.job_state_summary) {
// Check dataset states first (higher priority for actual data states)
if ("elements_states" in item && item.elements_states) {
const datasetSummary = new DatasetStateSummary(
item as {
elements_states?: Record<string, number>;
elements_deleted?: number;
populated_state?: string | null;
}
);
for (const datasetState of HIERARCHICAL_COLLECTION_DATASET_STATES) {
if (datasetSummary.get(datasetState) > 0) {
return datasetState;
}
}
}
// Fall back to job states if no dataset states
if ("job_state_summary" in item && item.job_state_summary) {
const jobStateSummary = item.job_state_summary as Record<string, number>;
for (const jobState of HIERARCHICAL_COLLECTION_JOB_STATES) {
if (item.job_state_summary[jobState] > 0) {
if ((jobStateSummary[jobState] || 0) > 0) {
return jobState;
}
}
}
} else if (item.accessible === false) {
} else if ("accessible" in item && item.accessible === false) {
return "inaccessible";
} else if (item.state) {
} else if ("state" in item && item.state) {
return item.state;
}
return "ok";
@@ -125,6 +125,8 @@ function mockCollection(id: string, numElements = 10): HDCASummary {
id: id,
element_count: numElements,
elements_datatypes: ["txt"],
elements_deleted: 0,
elements_states: {},
collection_type: "list",
populated_state: "ok",
populated_state_message: "",
+2 -4
View File
@@ -1520,9 +1520,7 @@ class MinimalJobWrapper(HasResourceParameters):
dep_job_assoc.job,
"Execution of this dataset's job is paused because its input datasets are in an error state.",
)
job.set_final_state(
job.states.ERROR, supports_skip_locked=self.app.application_stack.supports_skip_locked()
)
job.set_final_state(job.states.ERROR)
job.command_line = self.command_line
job.info = message
# TODO: Put setting the stdout, stderr, and exit code in one place
@@ -2292,7 +2290,7 @@ class MinimalJobWrapper(HasResourceParameters):
# Finally set the job state. This should only happen *after* all
# dataset creation, and will allow us to eliminate force_history_refresh.
job.set_final_state(final_job_state, supports_skip_locked=self.app.application_stack.supports_skip_locked())
job.set_final_state(final_job_state)
if not job.tasks:
# If job was composed of tasks, don't attempt to recollect statistics
self._collect_metrics(job, job_metrics_directory)
+1 -1
View File
@@ -1086,7 +1086,7 @@ class JobHandlerStopQueue(BaseJobHandlerQueue):
if error_msg is not None:
final_state = job.states.ERROR
job.info = error_msg
job.set_final_state(final_state, supports_skip_locked=self.app.application_stack.supports_skip_locked())
job.set_final_state(final_state)
self.sa_session.add(job)
self.sa_session.flush()
+7
View File
@@ -182,6 +182,13 @@ def dictify_element_reference(
object_details["element_count"] = element_object.element_count
object_details["populated"] = element_object.populated_optimized
# Add hierarchical state information for intermediate collections
# Add elements_states and intermediate_states from DatasetCollection
dataset_summary = element_object.dataset_states_and_extensions_summary
object_details["elements_states"] = dataset_summary.states
object_details["elements_deleted"] = dataset_summary.deleted
object_details["elements_datatypes"] = dataset_summary.extensions
# Recursively yield elements for each nested collection...
if recursive:
elements, rest_fuzzy_counts = get_fuzzy_count_elements(element_object, rank_fuzzy_counts)
+5 -1
View File
@@ -286,6 +286,9 @@ class HDCASerializer(DCASerializer, taggable.TaggableSerializerMixin, annotatabl
"populated_state",
"populated_state_message",
"element_count",
"elements_datatypes",
"elements_deleted",
"elements_states",
"job_source_id",
"job_source_type",
"job_state_summary",
@@ -305,7 +308,6 @@ class HDCASerializer(DCASerializer, taggable.TaggableSerializerMixin, annotatabl
[
"populated",
"elements",
"elements_datatypes",
],
include_keys_from="summary",
)
@@ -333,6 +335,8 @@ class HDCASerializer(DCASerializer, taggable.TaggableSerializerMixin, annotatabl
"contents_url": self.generate_contents_url,
"job_state_summary": self.serialize_job_state_summary,
"elements_datatypes": self.serialize_elements_datatypes,
"elements_states": lambda item, key, **context: item.dataset_dbkeys_and_extensions_summary[2],
"elements_deleted": lambda item, key, **context: item.dataset_dbkeys_and_extensions_summary[3],
"collection_id": self.serialize_id,
}
self.serializers.update(serializers)
+292 -72
View File
@@ -81,6 +81,7 @@ from sqlalchemy import (
Integer,
join,
JSON,
literal,
MetaData,
not_,
Numeric,
@@ -2235,44 +2236,15 @@ class Job(Base, JobLike, UsesCreateAndUpdateTime, Dictifiable, Serializable):
return rval
def update_hdca_update_time_for_job(self, update_time, sa_session, supports_skip_locked):
subq = (
sa_session.query(HistoryDatasetCollectionAssociation.id)
.join(ImplicitCollectionJobs)
.join(ImplicitCollectionJobsJobAssociation)
.filter(ImplicitCollectionJobsJobAssociation.job_id == self.id)
)
if supports_skip_locked:
subq = subq.with_for_update(skip_locked=True).subquery()
implicit_statement = (
HistoryDatasetCollectionAssociation.table.update()
.where(HistoryDatasetCollectionAssociation.id.in_(select(subq)))
.values(update_time=update_time)
)
def update_hdca_update_time_for_job(self, update_time, sa_session):
explicit_statement = (
HistoryDatasetCollectionAssociation.table.update()
.where(HistoryDatasetCollectionAssociation.job_id == self.id)
.values(update_time=update_time)
)
sa_session.execute(explicit_statement)
if supports_skip_locked:
sa_session.execute(implicit_statement)
else:
conn = sa_session.connection(execution_options={"isolation_level": "SERIALIZABLE"})
with conn.begin() as trans:
try:
conn.execute(implicit_statement)
trans.commit()
except OperationalError as e:
# If this is a serialization failure on PostgreSQL, then e.orig is a psycopg2 TransactionRollbackError
# and should have attribute `code`. Other engines should just report the message and move on.
if int(getattr(e.orig, "pgcode", -1)) != 40001:
log.debug(
f"Updating implicit collection uptime_time for job {self.id} failed (this is expected for large collections and not a problem): {unicodify(e)}"
)
trans.rollback()
def set_final_state(self, final_state, supports_skip_locked):
def set_final_state(self, final_state):
self.set_state(final_state)
# TODO: migrate to where-in subqueries?
statement = text(
@@ -2284,9 +2256,7 @@ class Job(Base, JobLike, UsesCreateAndUpdateTime, Dictifiable, Serializable):
)
sa_session = required_object_session(self)
update_time = now()
self.update_hdca_update_time_for_job(
update_time=update_time, sa_session=sa_session, supports_skip_locked=supports_skip_locked
)
self.update_hdca_update_time_for_job(update_time=update_time, sa_session=sa_session)
params = {"job_id": self.id, "update_time": update_time}
sa_session.execute(statement, params)
@@ -2350,9 +2320,22 @@ class Job(Base, JobLike, UsesCreateAndUpdateTime, Dictifiable, Serializable):
]
sa_session = required_object_session(self)
update_time = now()
# Update job-specific collection associations
self.update_hdca_update_time_for_job(
update_time=update_time, sa_session=sa_session, supports_skip_locked=supports_skip_locked
update_time=update_time,
sa_session=sa_session,
)
# Update general collection associations for all datasets in this job
# This handles collections that contain datasets from this job but aren't directly job-associated
job_datasets = sa_session.query(Dataset).filter(Dataset.job_id == self.id).all()
for dataset in job_datasets:
try:
dataset.touch_collection_update_time(supports_skip_locked=supports_skip_locked)
except Exception:
# Don't let collection update failures prevent job comion
log.error(f"Failed to update collection times for dataset {dataset.id} from job {self.id}")
params = {"job_id": self.id, "state": self.state, "info": self.info, "update_time": update_time}
for statement in statements:
sa_session.execute(statement, params)
@@ -4670,6 +4653,178 @@ class Dataset(Base, StorableObject, Serializable):
serialization_options.attach_identifier(id_encoder, self, rval)
return rval
def touch_collection_update_time(self, supports_skip_locked=None):
"""
Update time is important to determine for what history items we need to fetch
updated data. This method updates all collection associations that reference
this dataset through any of its dataset instances (HDAs, LDDAs, etc.).
Uses database-specific recursive CTEs for optimal performance.
Args:
supports_skip_locked: Boolean to indicate if database supports
FOR UPDATE SKIP LOCKED. Defaults to False.
"""
session = object_session(self)
if not session:
return
dialect_name = session.bind.dialect.name
if dialect_name in ("postgresql", "sqlite"):
self._touch_collection_update_time_cte(session, supports_skip_locked)
else:
# Fallback for other databases
self._touch_collection_update_time_fallback(session)
def _touch_collection_update_time_cte(self, session, supports_skip_locked):
"""CTE implementation using SQLAlchemy core constructs"""
# Base case columns
base_columns = [
DatasetCollectionElement.dataset_collection_id.label("collection_id"),
literal(0).label("depth_level"),
]
# Create a single base query that covers both HDA and LDDA cases using OR conditions
base_query = (
select(*base_columns)
.select_from(
DatasetCollectionElement.__table__.outerjoin(
HistoryDatasetAssociation.__table__, DatasetCollectionElement.hda_id == HistoryDatasetAssociation.id
).outerjoin(
LibraryDatasetDatasetAssociation.__table__,
DatasetCollectionElement.ldda_id == LibraryDatasetDatasetAssociation.id,
)
)
.where(
or_(
HistoryDatasetAssociation.dataset_id == self.id,
LibraryDatasetDatasetAssociation.dataset_id == self.id,
)
)
)
# Create the recursive CTE from the single base query
collection_hierarchy_cte = base_query.cte(name="collection_hierarchy", recursive=True)
# Create aliases for the recursive part
ch_alias = aliased(collection_hierarchy_cte, name="ch")
parent_dce_alias = aliased(DatasetCollectionElement, name="parent_dce")
# Recursive case columns - conditional based on database support
recursive_columns = [
parent_dce_alias.dataset_collection_id.label("collection_id"),
(ch_alias.c.depth_level + 1).label("depth_level"),
]
# Recursive case conditions
recursive_conditions = [
parent_dce_alias.child_collection_id == ch_alias.c.collection_id,
ch_alias.c.depth_level < 50,
]
# Recursive case: Parent collections
recursive_query = select(*recursive_columns).where(and_(*recursive_conditions))
# Add the recursive part to the CTE
collection_hierarchy_cte = collection_hierarchy_cte.union_all(recursive_query)
# Create the final subquery that selects and orders collection IDs
collection_ids_subquery = select(collection_hierarchy_cte.c.collection_id).order_by(
collection_hierarchy_cte.c.collection_id
)
if supports_skip_locked:
# Use FOR UPDATE SKIP LOCKED for better concurrency when supported
# First select the HDCAs we want to update with FOR UPDATE SKIP LOCKED
hdca_ids_to_update = (
session.execute(
select(HistoryDatasetCollectionAssociation.id)
.where(HistoryDatasetCollectionAssociation.collection_id.in_(collection_ids_subquery))
.order_by(HistoryDatasetCollectionAssociation.id)
.with_for_update(skip_locked=True)
)
.scalars()
.all()
)
# Then update only the locked HDCAs
if hdca_ids_to_update:
update_stmt = (
update(HistoryDatasetCollectionAssociation)
.where(HistoryDatasetCollectionAssociation.id.in_(hdca_ids_to_update))
.values(update_time=now())
)
session.execute(update_stmt)
else:
# Fallback to regular update without locking
update_stmt = (
update(HistoryDatasetCollectionAssociation)
.where(HistoryDatasetCollectionAssociation.collection_id.in_(collection_ids_subquery))
.values(update_time=now())
)
session.execute(update_stmt)
def _touch_collection_update_time_fallback(self, session):
"""Python-based fallback for databases without good CTE support"""
visited_collections = set()
hdcas_to_update = []
def collect_parent_hdcas(collection_id, depth=0):
if collection_id in visited_collections or depth > 50:
return
visited_collections.add(collection_id)
# Find HDCAs for this collection - ordered by ID for consistency
hdca_stmt = (
select(HistoryDatasetCollectionAssociation)
.where(HistoryDatasetCollectionAssociation.collection_id == collection_id)
.order_by(HistoryDatasetCollectionAssociation.id)
)
hdcas = session.scalars(hdca_stmt).all()
hdcas_to_update.extend(hdcas)
# Find parent collections - ordered by ID for consistency
parent_stmt = (
select(DatasetCollectionElement.dataset_collection_id)
.where(DatasetCollectionElement.child_collection_id == collection_id)
.order_by(DatasetCollectionElement.dataset_collection_id)
)
parent_collection_ids = session.scalars(parent_stmt).all()
for parent_id in sorted(parent_collection_ids):
collect_parent_hdcas(parent_id, depth + 1)
# Start with collections containing this dataset - ordered by ID for consistency
initial_stmt = (
select(DatasetCollectionElement.dataset_collection_id)
.where(
or_(
DatasetCollectionElement.hda_id.in_(
select(HistoryDatasetAssociation.id).where(HistoryDatasetAssociation.dataset_id == self.id)
),
DatasetCollectionElement.ldda_id.in_(
select(LibraryDatasetDatasetAssociation.id).where(
LibraryDatasetDatasetAssociation.dataset_id == self.id
)
),
)
)
.order_by(DatasetCollectionElement.dataset_collection_id)
)
initial_collection_ids = session.scalars(initial_stmt).all()
for collection_id in sorted(initial_collection_ids):
collect_parent_hdcas(collection_id)
# Update all collected HDCAs - sort by ID for consistent ordering
update_time = now()
for hdca in sorted(hdcas_to_update, key=lambda x: x.id):
hdca.update_time = update_time
session.add(hdca)
class DatasetSource(Base, Dictifiable, Serializable):
__tablename__ = "dataset_source"
@@ -4915,19 +5070,29 @@ class DatasetInstance(RepresentById, UsesCreateAndUpdateTime, _HasTable):
# self._state holds state that should only affect this particular dataset association, not the dataset state itself
if self._state:
return self._state
return self.dataset.state
return self.dataset.state if self.dataset else None
@state.setter
def state(self, state: Optional[DatasetState]):
def state(self, state: DatasetState):
if state != self.state:
if state in (DatasetState.FAILED_METADATA, DatasetState.SETTING_METADATA):
self._state = state
else:
self.set_metadata_success_state()
assert self.dataset, "Dataset must be set before setting state"
sa_session = object_session(self)
if sa_session:
sa_session.add(self.dataset)
self.dataset.state = state
self.touch_collection_update_time()
def touch_collection_update_time(self):
"""
Update time is important to determine for what history items we need to fetch
updated data.
"""
if self.dataset:
self.dataset.touch_collection_update_time()
def set_metadata_success_state(self):
self._state = None
@@ -6673,6 +6838,13 @@ class InnerCollectionFilter(NamedTuple):
return self.operator_function(getattr(table, self.column), self.expected_value)
class CollectionStateSummary(NamedTuple):
dbkeys: list[Union[str, None]]
extensions: list[str]
states: dict[str, int]
deleted: int
class DatasetCollection(Base, Dictifiable, UsesAnnotations, Serializable):
__tablename__ = "dataset_collection"
@@ -6694,8 +6866,14 @@ class DatasetCollection(Base, Dictifiable, UsesAnnotations, Serializable):
order_by=lambda: DatasetCollectionElement.element_index,
)
dict_collection_visible_keys = ["id", "collection_type"]
dict_element_visible_keys = ["id", "collection_type"]
dict_collection_visible_keys = [
"id",
"collection_type",
"elements_datatypes",
"elements_states",
"elements_deleted",
]
dict_element_visible_keys = ["id", "collection_type", "elements_datatypes", "elements_states", "elements_deleted"]
populated_states = DatasetCollectionPopulatedState
@@ -6807,12 +6985,8 @@ class DatasetCollection(Base, Dictifiable, UsesAnnotations, Serializable):
@property
def elements_deleted(self):
if not hasattr(self, "_elements_deleted"):
if session := object_session(self):
stmt = self._build_nested_collection_attributes_stmt(
hda_attributes=("deleted",), dataset_attributes=("deleted",)
)
stmt = stmt.exists().where(or_(HistoryDatasetAssociation.deleted == true(), Dataset.deleted == true()))
self._elements_deleted = session.execute(select(stmt)).scalar()
if object_session(self):
self._elements_deleted = self.dataset_states_and_extensions_summary.deleted
else:
self._elements_deleted = False
for dataset_instance in self.dataset_instances:
@@ -6822,40 +6996,76 @@ class DatasetCollection(Base, Dictifiable, UsesAnnotations, Serializable):
return self._elements_deleted
@property
def dataset_states_and_extensions_summary(self):
if not hasattr(self, "_dataset_states_and_extensions_summary"):
stmt = self._build_nested_collection_attributes_stmt(
hda_attributes=("extension",), dataset_attributes=("state",)
)
# With DISTINCT, all columns that appear in the ORDER BY clause must appear in the SELECT clause.
stmt = stmt.add_columns(*stmt._order_by_clauses)
stmt = stmt.distinct()
def elements_datatypes(self):
if not hasattr(self, "_elements_datatypes"):
if object_session(self):
self._elements_datatypes = self.dataset_states_and_extensions_summary.extensions
else:
self._elements_datatypes = defaultdict(int)
for dataset_instance in self.dataset_instances:
if dataset_instance.extension:
self._elements_datatypes[dataset_instance.extension] += 1
return self._elements_datatypes
@property
def elements_states(self):
if not hasattr(self, "_elements_states"):
if object_session(self):
self._elements_states = self.dataset_states_and_extensions_summary.states
else:
self._elements_states = defaultdict(int)
for dataset_instance in self.dataset_instances:
if dataset_instance.state:
self._elements_states[dataset_instance.state] += 1
return self._elements_states
@property
def dataset_states_and_extensions_summary(self):
"""
Calculate dataset states and extensions for this collection.
Returns (dbkeys, extensions, states, deleted) similar to HDCA method.
"""
if not hasattr(self, "_dataset_states_and_extensions_summary"):
stmt = self._build_nested_collection_attributes_stmt(
hda_attributes=("_metadata", "extension", "deleted"), dataset_attributes=("state",)
)
tuples = required_object_session(self).execute(stmt)
extensions = set()
states = set()
for extension, state, *_ in tuples: # we discard the added columns from the order-by clause
states.add(state)
extensions.add(extension)
self._dataset_states_and_extensions_summary = (states, extensions)
dbkeys = set()
states = defaultdict(int)
deleted = 0
for row in tuples:
if row is not None:
dbkey_field = row._metadata.get("dbkey")
if isinstance(dbkey_field, list):
for dbkey in dbkey_field:
dbkeys.add(dbkey)
else:
dbkeys.add(dbkey_field)
if row.extension:
extensions.add(row.extension)
if row.deleted:
deleted += 1
if row.state:
states[row.state] += 1
# Filter out None values before sorting
filtered_dbkeys = (dbkey for dbkey in dbkeys if dbkey is not None)
filtered_extensions = (ext for ext in extensions if ext is not None)
self._dataset_states_and_extensions_summary = CollectionStateSummary(
sorted(filtered_dbkeys), sorted(filtered_extensions), states, deleted
)
return self._dataset_states_and_extensions_summary
@property
def has_deferred_data(self):
if not hasattr(self, "_has_deferred_data"):
has_deferred_data = False
if session := object_session(self):
# TODO: Optimize by just querying without returning the states...
stmt = self._build_nested_collection_attributes_stmt(dataset_attributes=("state",))
tuples = session.execute(stmt)
for (state,) in tuples:
if state == Dataset.states.DEFERRED:
has_deferred_data = True
break
if object_session(self):
has_deferred_data = Dataset.states.DEFERRED in self.dataset_states_and_extensions_summary.states
else:
has_deferred_data = False
# This will be in a remote tool evaluation context, so can't query database
for dataset_element in self.dataset_elements_and_identifiers():
if dataset_element.hda.state == Dataset.states.DEFERRED:
@@ -7163,7 +7373,9 @@ class DatasetCollectionInstance(HasName, UsesCreateAndUpdateTime):
populated_state=self.collection.populated_state,
populated_state_message=self.collection.populated_state_message,
element_count=self.collection.element_count,
elements_datatypes=list(self.dataset_dbkeys_and_extensions_summary[1]),
elements_datatypes=list(self.collection.elements_datatypes),
elements_deleted=self.collection.elements_deleted,
elements_states=self.collection.elements_states,
type="collection", # contents type (distinguished from file or folder (in case of library))
)
@@ -7369,11 +7581,15 @@ class HistoryDatasetCollectionAssociation(
@property
def dataset_dbkeys_and_extensions_summary(self):
if not hasattr(self, "_dataset_dbkeys_and_extensions_summary"):
stmt = self.collection._build_nested_collection_attributes_stmt(hda_attributes=("_metadata", "extension"))
stmt = self.collection._build_nested_collection_attributes_stmt(
hda_attributes=("_metadata", "extension", "deleted"), dataset_attributes=("state",)
)
tuples = required_object_session(self).execute(stmt)
extensions = set()
dbkeys = set()
states = defaultdict(int)
deleted = 0
for row in tuples:
if row is not None:
dbkey_field = row._metadata.get("dbkey")
@@ -7384,7 +7600,11 @@ class HistoryDatasetCollectionAssociation(
dbkeys.add(dbkey_field)
if row.extension:
extensions.add(row.extension)
self._dataset_dbkeys_and_extensions_summary = (dbkeys, extensions)
if row.deleted:
deleted += 1
if row.state:
states[row.state] += 1
self._dataset_dbkeys_and_extensions_summary = (dbkeys, extensions, states, deleted)
return self._dataset_dbkeys_and_extensions_summary
@property
@@ -7458,7 +7678,7 @@ class HistoryDatasetCollectionAssociation(
def to_dict(self, view="collection"):
original_dict_value = super().to_dict(view=view)
if view == "dbkeysandextensions":
(dbkeys, extensions) = self.dataset_dbkeys_and_extensions_summary
(dbkeys, extensions, *_) = self.dataset_dbkeys_and_extensions_summary
dict_value = dict(
dbkey=dbkeys.pop() if len(dbkeys) == 1 else "?",
extension=extensions.pop() if len(extensions) == 1 else "auto",
@@ -1,3 +1,4 @@
from collections import defaultdict
from typing import (
TYPE_CHECKING,
)
@@ -5,6 +6,7 @@ from typing import (
from pydantic import ValidationError
from typing_extensions import Self
from galaxy.model import CollectionStateSummary
from galaxy.tool_util_models.parameters import (
AdaptedDataCollectionPromoteCollectionElementToCollectionRequestInternal,
AdaptedDataCollectionPromoteDatasetsToCollectionRequestInternal,
@@ -88,11 +90,11 @@ class DCECollectionAdapter(CollectionAdapter):
return self._dce.child_collection.dataset_states_and_extensions_summary
else:
hda = self._dce.dataset_instance
extensions = set()
states = set()
states.add(hda.dataset.state)
extensions.add(hda.extension)
return (states, extensions)
dbkeys = [hda.dbkey] if hda.dbkey else []
extensions = [hda.extension] if hda.extension else []
states = {hda.dataset.state: 1} if hda.dataset.state else {}
deleted = 1 if hda.deleted or (hda.dataset and hda.dataset.deleted) else 0
return CollectionStateSummary(dbkeys=dbkeys, extensions=extensions, states=states, deleted=deleted)
@property
def dataset_instances(self):
@@ -161,11 +163,11 @@ class PromoteDatasetToCollection(CollectionAdapter):
@property
def dataset_states_and_extensions_summary(self):
hda = self._hda
extensions = set()
states = set()
states.add(hda.dataset.state)
extensions.add(hda.extension)
return (states, extensions)
dbkeys = [hda.dbkey] if hda.dbkey else []
extensions = [hda.extension] if hda.extension else []
states = {hda.dataset.state: 1} if hda.dataset.state else {}
deleted = 1 if hda.deleted or (hda.dataset and hda.dataset.deleted) else 0
return CollectionStateSummary(dbkeys=dbkeys, extensions=extensions, states=states, deleted=deleted)
@property
def dataset_instances(self):
@@ -234,12 +236,22 @@ class PromoteDatasetsToCollection(CollectionAdapter):
@property
def dataset_states_and_extensions_summary(self):
dbkeys = set()
extensions = set()
states = set()
states: dict[str, int] = defaultdict(int)
deleted = 0
for hda in self.dataset_instances:
states.add(hda.dataset.state)
extensions.add(hda.extension)
return (states, extensions)
if hda.dbkey:
dbkeys.add(hda.dbkey)
if hda.extension:
extensions.add(hda.extension)
if hda.dataset.state:
states[hda.dataset.state] += 1
if hda.deleted or (hda.dataset and hda.dataset.deleted):
deleted += 1
return CollectionStateSummary(
dbkeys=sorted(dbkeys), extensions=sorted(extensions), states=states, deleted=deleted
)
@property
def adapting(self):
+36
View File
@@ -97,6 +97,23 @@ class DatasetState(str, Enum):
return self.__members__.values()
# Create dictionary for ElementsStatesDict using class syntax
class ElementsStatesDict(TypedDict, total=False):
# Add fields for each DatasetState value
new: NotRequired[int]
upload: NotRequired[int]
queued: NotRequired[int]
running: NotRequired[int]
ok: NotRequired[int]
empty: NotRequired[int]
error: NotRequired[int]
paused: NotRequired[int]
setting_metadata: NotRequired[int]
failed_metadata: NotRequired[int]
deferred: NotRequired[int]
discarded: NotRequired[int]
class JobState(str, Enum):
NEW = "new"
RESUBMITTED = "resubmitted"
@@ -1032,6 +1049,17 @@ class DCObject(Model, WithModelClass):
element_count: ElementCountField
contents_url: Optional[ContentsUrlField] = None
elements: list["DCESummary"] = ElementsField
elements_states: ElementsStatesDict = Field(
..., description="A dictionary containing counts for each dataset state in the collection."
)
elements_deleted: int = Field(
...,
title="Datasets deleted",
description="The number of elements in the collection that are marked as deleted.",
)
elements_datatypes: set[str] = Field(
..., description="A set containing all the different element datatypes in the collection."
)
class DCESummary(Model, WithModelClass):
@@ -1180,6 +1208,14 @@ class HDCASummary(HDCACommon, WithModelClass):
elements_datatypes: set[str] = Field(
..., description="A set containing all the different element datatypes in the collection."
)
elements_states: ElementsStatesDict = Field(
..., description="A dictionary containing counts for each dataset state in the collection."
)
elements_deleted: int = Field(
...,
title="Datasets deleted",
description="The number of elements in the collection that are marked as deleted.",
)
job_source_id: Optional[EncodedDatabaseIdField] = Field(
None,
title="Job Source ID",
+3 -2
View File
@@ -3619,8 +3619,9 @@ class DatabaseOperationTool(Tool):
if not input_dataset_collection.collection.populated_optimized:
raise ToolInputsNotReadyException("An input collection is not populated.")
states, _ = input_dataset_collection.collection.dataset_states_and_extensions_summary
for state in states:
summary = input_dataset_collection.collection.dataset_states_and_extensions_summary
states = summary.states
for state in states.keys():
check_dataset_state(input_key, state)
def _add_datasets_to_history(self, history, elements, datasets_visible=False):
+2 -1
View File
@@ -293,7 +293,8 @@ class DefaultToolAction(ToolAction):
for action, role_id in action_tuples:
record_permission(action, role_id)
_, extensions = collection.dataset_states_and_extensions_summary
summary = collection.dataset_states_and_extensions_summary
extensions = summary.extensions
conversion_required = False
for ext in extensions:
if ext:
@@ -213,8 +213,10 @@ class SummaryDatasetCollectionMatcher:
if not dataset_collection.populated_optimized:
return False
(states, extensions) = dataset_collection.dataset_states_and_extensions_summary
for state in states:
summary = dataset_collection.dataset_states_and_extensions_summary
states = summary.states
extensions = summary.extensions
for state in states.keys():
if state not in self.dataset_matcher_factory.valid_input_states:
return False
+1 -1
View File
@@ -499,7 +499,7 @@ PRJDA60709 SAMD00016382 DRX000480 ftp.sra.ebi.ac.uk/vol1/fastq/DRR000/DRR0
self.screenshot("rules_deferred_list_7_named")
rule_builder.main_button_ok.wait_for_and_click()
hid = 2
self.history_panel_wait_for_hid_ok(hid)
self.history_panel_wait_for_hid_state(hid, state="deferred", allowed_force_refreshes=1)
self.screenshot("rules_deferred_list_7_download_complete")
def _read_rules_test_data_file(self, name):
@@ -375,10 +375,15 @@ history_dataset_display(history_dataset_id=2)
history_dataset_collection_display(history_dataset_collection_id=1)
```
"""
# Patch out url_for since we haven't initialized an app.
# Mock the HDCASerializer to return a dummy serialized view
from galaxy.managers.hdcas import HDCASerializer
with mock.patch.object(HDCASerializer, "url_for", return_value="http://google.com"):
mock_hdca_view = {"id": "encoded_id_1", "name": "cool name", "collection_type": "paired"}
with (
mock.patch.object(HDCASerializer, "url_for", return_value="http://google.com"),
mock.patch.object(HDCASerializer, "serialize_to_view", return_value=mock_hdca_view),
):
export, extra_data = self._ready_export(example)
assert "history_dataset_collections" in extra_data
assert len(extra_data.get("history_dataset_collections")) == 1
+12 -6
View File
@@ -9,17 +9,23 @@ from .util import BaseParameterTestCase
class TestSelectToolParameter(BaseParameterTestCase):
def new_hda(self):
hda = model.HistoryDatasetAssociation()
hda._state = model.Dataset.states.OK
return hda
def test_validated_values(self):
self.options_xml = """<options><filter type="data_meta" ref="input_bam" key="dbkey"/></options>"""
with pytest.raises(ValueError) as exc_info:
self.param.from_json("42", self.trans, {"input_bam": model.HistoryDatasetAssociation()})
assert str(exc_info.value) == "parameter 'my_name': requires a value, but no legal values defined"
self.param.from_json("42", self.trans, {"input_bam": self.new_hda()})
assert str(exc_info.value) == "Parameter 'my_name': requires a value, but no legal values defined"
def test_validated_values_missing_dependency(self):
self.options_xml = """<options><filter type="data_meta" ref="input_bam" key="dbkey"/></options>"""
with pytest.raises(ValueError) as exc_info:
self.param.from_json("42", self.trans)
assert str(exc_info.value) == "parameter 'my_name': requires a value, but no legal values defined"
assert str(exc_info.value) == "Parameter 'my_name': requires a value, but no legal values defined"
def test_unvalidated_values(self):
self.options_xml = """<options><filter type="data_meta" ref="input_bam" key="dbkey"/></options>"""
@@ -29,14 +35,14 @@ class TestSelectToolParameter(BaseParameterTestCase):
def test_validated_datasets(self):
self.options_xml = """<options><filter type="data_meta" ref="input_bam" key="dbkey"/></options>"""
with pytest.raises(ValueError) as exc_info:
self.param.from_json(model.HistoryDatasetAssociation(), self.trans, {"input_bam": None})
assert str(exc_info.value) == "parameter 'my_name': requires a value, but no legal values defined"
self.param.from_json(self.new_hda(), self.trans, {"input_bam": None})
assert str(exc_info.value) == "Parameter 'my_name': requires a value, but no legal values defined"
def test_unvalidated_datasets(self):
self.options_xml = """<options><filter type="data_meta" ref="input_bam" key="dbkey"/></options>"""
self.trans.workflow_building_mode = True
assert isinstance(
self.param.from_json(model.HistoryDatasetAssociation(), self.trans, {"input_bam": RuntimeValue()}),
self.param.from_json(self.new_hda(), self.trans, {"input_bam": RuntimeValue()}),
model.HistoryDatasetAssociation,
)
@@ -0,0 +1,356 @@
"""
Unit tests for Dataset.touch_collection_update_time() functionality.
Tests both the recursive CTE approach and the Python fallback implementation
across different database backends and collection hierarchy scenarios.
"""
import uuid
from unittest.mock import patch
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from galaxy import model
from galaxy.model.unittest_utils.model_testing_utils import persist
@pytest.fixture(scope="module")
def engine():
"""Create test database engine."""
db_uri = "sqlite:///:memory:"
return create_engine(db_uri)
@pytest.fixture(scope="module")
def init_model(engine):
"""Create model objects in the engine's database."""
model.mapper_registry.metadata.create_all(engine)
@pytest.fixture
def session(init_model, engine):
"""Create test database session with all Galaxy model tables."""
with Session(engine) as s:
yield s
@pytest.fixture
def user(session):
"""Create a test user."""
unique_id = uuid.uuid4().hex[:8]
user = model.User(username=f"test_user_{unique_id}", email=f"test_{unique_id}@example.com", password="password123")
user_id = persist(session, user)
return session.get(model.User, user_id)
@pytest.fixture
def history(session, user):
"""Create a test history."""
history = model.History(name="Test History", user=user)
history_id = persist(session, history)
return session.get(model.History, history_id)
@pytest.fixture
def dataset(session):
"""Create a test dataset."""
dataset = model.Dataset(state="ok")
dataset_id = persist(session, dataset)
return session.get(model.Dataset, dataset_id)
@pytest.fixture
def hda(session, history, dataset):
"""Create a test HistoryDatasetAssociation."""
hda = model.HistoryDatasetAssociation(dataset=dataset, history=history, name="Test Dataset", hid=1)
hda_id = persist(session, hda)
return session.get(model.HistoryDatasetAssociation, hda_id)
def create_simple_collection_hierarchy(session, hda):
"""
Create a simple collection hierarchy:
HDCA -> Collection -> DCE -> HDA
"""
# Create a collection
collection = model.DatasetCollection(collection_type="list")
collection_id = persist(session, collection)
collection = session.get(model.DatasetCollection, collection_id)
# Create collection element linking collection to HDA
dce = model.DatasetCollectionElement(
collection=collection, element=hda, element_identifier="item1", element_index=0
)
persist(session, dce)
# Create HDCA
hdca = model.HistoryDatasetCollectionAssociation(
collection_id=collection.id, history_id=hda.history.id, name="Test Collection", hid=2
)
hdca_id = persist(session, hdca)
return session.get(model.HistoryDatasetCollectionAssociation, hdca_id)
def create_nested_collection_hierarchy(session, hda):
"""
Create a nested collection hierarchy:
HDCA -> Collection1 -> DCE -> Collection2 -> DCE -> HDA
"""
# Create inner collection (contains the HDA)
inner_collection = model.DatasetCollection(collection_type="list")
inner_collection_id = persist(session, inner_collection)
inner_collection = session.get(model.DatasetCollection, inner_collection_id)
# Create DCE linking inner collection to HDA
inner_dce = model.DatasetCollectionElement(
collection=inner_collection, element=hda, element_identifier="inner_item", element_index=0
)
persist(session, inner_dce)
# Create outer collection
outer_collection = model.DatasetCollection(collection_type="list:list")
outer_collection_id = persist(session, outer_collection)
outer_collection = session.get(model.DatasetCollection, outer_collection_id)
# Create DCE linking outer collection to inner collection
outer_dce = model.DatasetCollectionElement(
collection=outer_collection, element=inner_collection, element_identifier="outer_item", element_index=0
)
persist(session, outer_dce)
# Create HDCA for outer collection
hdca = model.HistoryDatasetCollectionAssociation(
collection_id=outer_collection.id, history_id=hda.history.id, name="Nested Collection", hid=3
)
hdca_id = persist(session, hdca)
return session.get(model.HistoryDatasetCollectionAssociation, hdca_id)
def create_multiple_hdca_hierarchy(session, hda):
"""
Create a hierarchy with multiple HDCAs referencing the same collection:
HDCA1 -> Collection -> DCE -> HDA
HDCA2 -> Collection (same as above)
"""
# Create a collection
collection = model.DatasetCollection(collection_type="list")
collection_id = persist(session, collection)
collection = session.get(model.DatasetCollection, collection_id)
# Create collection element linking collection to HDA
dce = model.DatasetCollectionElement(
collection=collection, element=hda, element_identifier="shared_item", element_index=0
)
persist(session, dce)
# Create first HDCA
hdca1 = model.HistoryDatasetCollectionAssociation(
collection_id=collection.id, history_id=hda.history.id, name="Shared Collection 1", hid=4
)
hdca1_id = persist(session, hdca1)
# Create second HDCA referencing the same collection
hdca2 = model.HistoryDatasetCollectionAssociation(
collection_id=collection.id, history_id=hda.history.id, name="Shared Collection 2", hid=5
)
hdca2_id = persist(session, hdca2)
return (
session.get(model.HistoryDatasetCollectionAssociation, hdca1_id),
session.get(model.HistoryDatasetCollectionAssociation, hdca2_id),
)
class TestTouchCollectionUpdateTime:
"""Test cases for Dataset.touch_collection_update_time() method."""
def test_no_collections(self, session, dataset):
"""Test that touch_collection_update_time works when dataset is not in any collections."""
# Should not raise any errors
dataset.touch_collection_update_time()
session.commit()
def test_simple_collection_hierarchy(self, session, hda):
"""Test updating a simple collection hierarchy."""
hdca = create_simple_collection_hierarchy(session, hda)
# Record original update time
original_time = hdca.update_time
# Touch collection update time
hda.dataset.touch_collection_update_time()
session.commit()
# Refresh from database
session.refresh(hdca)
# Verify update time was changed
assert hdca.update_time > original_time
def test_nested_collection_hierarchy(self, session, hda):
"""Test updating a nested collection hierarchy."""
hdca = create_nested_collection_hierarchy(session, hda)
# Record original update time
original_time = hdca.update_time
# Touch collection update time
hda.dataset.touch_collection_update_time()
session.commit()
# Refresh from database
session.refresh(hdca)
# Verify update time was changed
assert hdca.update_time > original_time
def test_multiple_hdcas_same_collection(self, session, hda):
"""Test updating multiple HDCAs that reference the same collection."""
hdca1, hdca2 = create_multiple_hdca_hierarchy(session, hda)
# Record original update times
original_time1 = hdca1.update_time
original_time2 = hdca2.update_time
# Touch collection update time
hda.dataset.touch_collection_update_time()
session.commit()
# Refresh from database
session.refresh(hdca1)
session.refresh(hdca2)
# Verify both HDCAs were updated
assert hdca1.update_time > original_time1
assert hdca2.update_time > original_time2
def test_multiple_hdas_same_dataset(self, session, dataset, history, user):
"""Test updating collections when dataset has multiple HDAs."""
# Create two HDAs for the same dataset
hda1 = model.HistoryDatasetAssociation(dataset=dataset, history=history, name="Test Dataset 1", hid=1)
persist(session, hda1)
hda2 = model.HistoryDatasetAssociation(dataset=dataset, history=history, name="Test Dataset 2", hid=2)
persist(session, hda2)
# Create collections for each HDA
hdca1 = create_simple_collection_hierarchy(session, hda1)
hdca2 = create_simple_collection_hierarchy(session, hda2)
# Record original update times
original_time1 = hdca1.update_time
original_time2 = hdca2.update_time
# Touch collection update time on the dataset
dataset.touch_collection_update_time()
session.commit()
# Refresh from database
session.refresh(hdca1)
session.refresh(hdca2)
# Verify both HDCAs were updated
assert hdca1.update_time > original_time1
assert hdca2.update_time > original_time2
def test_cte_approach(self, session, hda):
"""Test CTE implementation."""
hdca = create_simple_collection_hierarchy(session, hda)
original_time = hdca.update_time
# This should use the SQLite CTE path
hda.dataset.touch_collection_update_time()
session.commit()
assert hdca.update_time > original_time
def test_fallback_approach(self, session, hda):
"""Test Python fallback implementation updates HDCA properly."""
hdca = create_simple_collection_hierarchy(session, hda)
# Record original update time
original_time = hdca.update_time
# Create a second session to avoid the dialect mock interfering with refresh
with Session(session.bind) as fallback_session:
# Get the dataset and hda in the new session
dataset = fallback_session.get(model.Dataset, hda.dataset.id)
assert dataset
# Mock the dialect on the fallback session to force Python fallback
with patch.object(fallback_session.bind, "dialect") as mock_dialect:
mock_dialect.name = "unknown_db"
# This should use the Python fallback path
dataset.touch_collection_update_time()
fallback_session.commit()
# Refresh the HDCA in the original session to see the updates
session.refresh(hdca)
# Verify update time was changed
assert hdca.update_time > original_time
def test_depth_limiting(self, session, hda):
"""Test that very deep hierarchies are handled with depth limiting."""
# Create a deep hierarchy (this tests the depth limit logic)
current_collection = None
collections = []
# Create 5 levels of nested collections
for i in range(5):
collection = model.DatasetCollection(collection_type=f"list{':list' * i}")
collection_id = persist(session, collection)
collection = session.get(model.DatasetCollection, collection_id)
collections.append(collection)
if i == 0:
# First level: connect to HDA
dce = model.DatasetCollectionElement(
collection=collection, element=hda, element_identifier=f"item_{i}", element_index=0
)
else:
# Subsequent levels: connect to previous collection
dce = model.DatasetCollectionElement(
collection=collection, element=current_collection, element_identifier=f"item_{i}", element_index=0
)
persist(session, dce)
current_collection = collection
# Create HDCA for the top-level collection
hdca = model.HistoryDatasetCollectionAssociation(
collection=current_collection, history=hda.history, name="Deep Collection", hid=10
)
hdca_id = persist(session, hdca)
hdca = session.get(model.HistoryDatasetCollectionAssociation, hdca_id)
original_time = hdca.update_time
# Should handle deep hierarchy without issues
hda.dataset.touch_collection_update_time()
session.commit()
session.refresh(hdca)
assert hdca.update_time > original_time
def test_empty_collections(self, session, history):
"""Test behavior with empty collections (no elements)."""
# Create empty collection
collection = model.DatasetCollection(collection_type="list")
collection_id = persist(session, collection)
collection = session.get(model.DatasetCollection, collection_id)
# Create HDCA for empty collection
hdca = model.HistoryDatasetCollectionAssociation(
collection=collection, history=history, name="Empty Collection", hid=1
)
persist(session, hdca)
# Create a dataset not in any collection and get it back from session
dataset = model.Dataset(state="ok")
dataset_id = persist(session, dataset)
dataset = session.get(model.Dataset, dataset_id)
# Should not affect empty collections
dataset.touch_collection_update_time()
session.commit()
+10 -2
View File
@@ -188,7 +188,11 @@ class TestMappings(BaseModelTestCase):
assert c2.dataset_elements == [dce1, dce2]
assert c2.dataset_action_tuples == []
assert c2.populated_optimized
assert c2.dataset_states_and_extensions_summary == ({"new"}, {"txt", "bam"})
summary = c2.dataset_states_and_extensions_summary
extensions = summary.extensions
states = summary.states
assert states == {"new": 2}
assert extensions == ["bam", "txt"]
assert c2.element_identifiers_extensions_paths_and_metadata_files == [
[
("inner_list", "forward"),
@@ -200,7 +204,11 @@ class TestMappings(BaseModelTestCase):
]
assert c3.dataset_instances == []
assert c3.dataset_elements == []
assert c3.dataset_states_and_extensions_summary == (set(), set())
summary_c3 = c3.dataset_states_and_extensions_summary
dbkeys_c3 = summary_c3.dbkeys
extensions_c3 = summary_c3.extensions
assert not dbkeys_c3
assert not extensions_c3
stmt = c4._build_nested_collection_attributes_stmt(element_attributes=("element_identifier",))
result = self.model.session.execute(stmt).all()