Skip to content

Generalize derivation filters and properties from Circuit to all Entities#647

Open
pgetta wants to merge 5 commits into
mainfrom
feat/generalize-derivation-filter-expand-to-entity
Open

Generalize derivation filters and properties from Circuit to all Entities#647
pgetta wants to merge 5 commits into
mainfrom
feat/generalize-derivation-filter-expand-to-entity

Conversation

@pgetta

@pgetta pgetta commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Alternative implementation of #642.

Where #642 adds the derivation-backed filters and expand-read properties to Circuit only, this PR lifts them up to the Entity base so every entity subclass inherits them. It also adds an entity-id filter (used_id / generated_id, + __in) alongside derivation_type.

  • Filter — centralized in EntityFilterMixin + factory.py (joins auto-injected per entity model, applied only when the filter is set), so no per-service filter wiring.
  • Expand-readgenerated_derivations / used_derivations live on EntityRead / EntityReadWoutAssets, always present and null until ?expand= is used; expand is wired into read_one, admin_read_one, read_many, admin_read_many across all entity services.
  • No DB migration: the derivation table already exists and the new relationships are viewonly.

pgetta added 3 commits June 25, 2026 09:06
Expose circuit derivations on GET /circuit, derived from the existing
Derivation table (no migration, no denormalized column).

- Filter (always available), both directions mirroring the read fields:
  generated_derivation__derivation_type (circuit is the generated/derived
  entity) and used_derivation__derivation_type (circuit is the used/source
  entity), each with an __in variant. No source-type restriction, so e.g.
  an emodel->circuit emodel_circuit derivation matches on the generated side.
- Read columns (opt-in via ?expand=...): generated_derivations and
  used_derivations, expandable independently. Unexpanded directions are
  omitted; expanded but empty serialize as []. Load-aware properties keep
  this safe under raiseload("*") via two viewonly relationships on Circuit.
The paginated id subquery in `_with_subquery` selected ids without DISTINCT,
so a one-to-many filter join (e.g. the circuit derivation-type filters, where a
circuit can match several Derivation rows) duplicated the row. OFFSET/LIMIT then
paged over the duplicated rows while total_items used count(distinct id), so a
matching entity could repeat across pages or be skipped.

Select DISTINCT ids in the subquery so the limit window operates on distinct
entities. This also hardens the pre-existing contribution/mtype/used/generated
one-to-many filters. DISTINCT is valid here because every ORDER BY element is
already part of the subquery select list.

Add a regression test asserting a circuit matched by multiple derivation rows is
counted once and does not reappear on a second page.
Lift the Derivation-backed filter and expand-read capabilities from Circuit
up to the Entity base so every entity subclass inherits them, and add a new
entity-id filter alongside derivation_type.

Filter (centralized, no per-service edits):
- Move NestedDerivationFilter to EntityFilterMixin, extended with used_id /
  generated_id (+ __in) to filter by the related entity, not only by
  derivation_type.
- factory.py auto-injects the derivation joins and distinct aliases for any
  Entity model; joins apply only when the filter is set, so default lists
  pay nothing.

Expand-read (centralized core + per-service param):
- Move the viewonly derivations relationships and load-aware properties from
  Circuit to Entity.
- GeneratedDerivationRead / UsedDerivationRead + DerivationReadMixin on both
  EntityRead and EntityReadWoutAssets; always present, null until expanded.
- Shared EntityExpand / ExpandDep / apply_derivation_expand applied in
  router_read_one and router_read_many.
- Wire expand into read_one, admin_read_one, read_many, admin_read_many across
  all entity services; cell_morphology / em_cell_mesh keep their
  measurement_annotation expand, unified with the derivation expands.

No DB migration: the derivation table already exists and the new relationships
are viewonly.
@codecov

codecov Bot commented Jun 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.71429% with 6 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
app/filters/base.py 50.00% 4 Missing ⚠️
app/service/em_cell_mesh.py 66.66% 2 Missing ⚠️
Flag Coverage Δ
pytest 97.71% <95.71%> (-0.04%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
app/db/model.py 99.16% <100.00%> (+0.01%) ⬆️
app/dependencies/common.py 100.00% <100.00%> (ø)
app/filters/circuit.py 100.00% <ø> (ø)
app/filters/entity.py 100.00% <100.00%> (ø)
app/queries/common.py 98.23% <100.00%> (+0.04%) ⬆️
app/queries/expand.py 100.00% <100.00%> (ø)
app/queries/factory.py 100.00% <100.00%> (ø)
app/schemas/entity.py 100.00% <100.00%> (ø)
app/service/analysis_notebook_environment.py 100.00% <100.00%> (ø)
app/service/analysis_notebook_result.py 100.00% <100.00%> (ø)
... and 37 more
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread app/queries/common.py
)
if apply_operations:
query = apply_operations(query)
query = apply_derivation_expand(query, db_model_class, expand)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be applied only to entities?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It already is - apply_derivation_expand early-returns unless issubclass(db_model_class, Entity) (and when expand is empty), so it's a no-op for non-entity models.

Comment thread app/filters/base.py Outdated
Comment on lines +243 to +248
try:
attr = attrgetter(name)(self)
except AttributeError:
# A join may be registered (e.g. the derivation joins, added for every entity model)
# without this filter declaring the matching field; treat it as not filtering.
return False

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure I understand why is this needed. Isn't it possible to avoid silently skipping errors? If nevertheless is essential could you cover it in tests so that it is clear what is the expected behavior of this bit?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch - these were effectively unreachable, I've removed the try/except block.

Comment thread app/filters/base.py Outdated
Comment on lines +259 to +262
try:
attr = attrgetter(name)(self)
except AttributeError:
return None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be fixed as well.

Comment thread app/queries/factory.py

def _is_entity_model(db_model_class: Any) -> bool:
"""Whether the model is an Entity subclass (kept separate to avoid narrowing the caller)."""
return isinstance(db_model_class, type) and issubclass(db_model_class, Entity)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it indeed the case that the type needs to be as broad as "Any"?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, I've narrowed the param to object.

Comment thread app/filters/entity.py
derivation_type: DerivationType | None = None
derivation_type__in: list[DerivationType] | None = None
used_id: uuid.UUID | None = None
used_id__in: list[uuid.UUID] | None = None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, this can be useful for selecting cell morphologies derived from one or more EMDenseReconstructionDataset with derivation_type em_dense_reconstruction_dataset_cell_morphology and support pagination.

See this comment

Comment thread app/db/model.py Outdated
)

@property
def generated_derivations(self) -> list["Derivation"] | None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about naming, what about calling the properties:
generated_from_derivations and used_by_derivations?
Not much longer, but it looks slightly clearer to me.
Other ideas?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, clearer. Renamed: generated_derivations -> generated_from_derivations and used_derivations -> used_by_derivations (properties, response fields, and the ?expand= values).

Comment thread app/filters/circuit.py Outdated

# generated_derivation / used_derivation are inherited from EntityFilterMixin via
# ScientificArtifactFilter, so every entity filter exposes them.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment isn't very useful since it's the same for every entity, so it can be removed,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed.

Comment thread app/db/model.py
@property
def generated_derivations(self) -> list["Derivation"] | None:
"""Derivations where this entity is the generated entity, or None if not expanded."""
if "derivations_as_generated" in sa.inspect(self).unloaded:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need that guard?
If the property is accessed without loading the derivations, it could be better to fail rather than returning None.
Removing the guard can be acceptable only if we are sure that the property is accessed only when required.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked better the code and these properties are accessed even when expand is empty, so the guard is needed.

Comment thread app/queries/factory.py
"generated_derivation": aliased(Derivation, flat=True),
"used_derivation": aliased(Derivation, flat=True),
}

@GianlucaFicarelli GianlucaFicarelli Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mutating the aliases dict seems a bit fragile, and unexpected by the caller.
An alternative could be to provide a function to build the aliases, that is called in each read_many instead of initializing the aliases dict directly.
However, it requires more changes and it would be easier to review if it's done in a separate PR (that can be also done after this one, I can have a look as well).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, Gianluca, I'll note this down - can look a bit later (in case you don't address it in the meantime).

user_context: UserContextDep,
db: SessionDep,
id_: uuid.UUID,
expand: ExpandDep = None,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a bit annoying to add expand to each endpoint, but I don't see a cleaner way to avoid those repetitions 🤔

- Rename expand keys/properties: generated_derivations -> generated_from_derivations
  and used_derivations -> used_by_derivations (params, response fields, properties)
- Remove unreachable try/except in CustomFilter nested-field helpers so a genuine
  registered-join/undeclared-field mismatch fails loudly
- Drop redundant inherited-derivation comment in CircuitFilter
- Narrow _is_entity_model param from Any to object
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants