Skip to content

feat(entity): support polymorphic-table cascades via additionalFieldFilters#615

Draft
szdziedzic wants to merge 1 commit into
mainfrom
szdziedzic/entity-additional-field-filters
Draft

feat(entity): support polymorphic-table cascades via additionalFieldFilters#615
szdziedzic wants to merge 1 commit into
mainfrom
szdziedzic/entity-additional-field-filters

Conversation

@szdziedzic
Copy link
Copy Markdown

@szdziedzic szdziedzic commented May 15, 2026

Summary

Adds an optional additionalFieldFilters field on EntityAssociationDefinition that AND's extra field-equality conditions onto the cascade load when the source entity is deleted. This lets polymorphic child tables — multiple entity classes backed by one DB table and distinguished by scope-specific columns — register each class with a scope-disambiguating filter so cascade loads through one class never see rows that belong to a sibling class (which would otherwise trip the wrong-scope constructor invariant and abort the entire cascade).

Motivation

For polymorphic tables where multiple entity classes share a table and each enforces a scope-specific constructor invariant (e.g. TurtleJobRunEntity requires appId !== null, AccountTurtleJobRunEntity requires accountId !== null), the parent-delete cascade currently loads every row matching the foreign key regardless of scope. Loading mixed-scope rows through one class throws on the wrong-scope rows and aborts the cascade. Consumers had to work around this with helper functions; this PR makes the scope filter expressible in the association config itself.

Mechanism

  • New FieldEqualityCondition types live in the base @expo/entity package (re-exported from @expo/entity-database-adapter-knex for back-compat).
  • New fetchManyByFieldEqualityConjunctionAsync on the base EntityDatabaseAdapter with a default impl that uses single-value operands for the SQL WHERE and applies multi-value operands as an in-memory filter. The knex adapter overrides it for native SQL conjunction filtering (with proper IS NULL semantics).
  • New loadManyByFieldEqualityConjunctionAsync on both AuthorizationResultBasedEntityLoader and EnforcingEntityLoader.
  • The cascade in AuthorizationResultBasedEntityMutator.processEntityDeletionForInboundEdgesAsync builds [{ primary }, ...association.additionalFieldFilters] and calls the new loader method. All four EntityEdgeDeletionBehavior branches (CASCADE_DELETE, CASCADE_DELETE_INVALIDATE_CACHE_ONLY, SET_NULL, SET_NULL_INVALIDATE_CACHE_ONLY) benefit automatically.
  • additionalFieldFilters is typed via a new TSourceFields generic threaded through EntityFieldDefinition, its options, and EntityConfiguration.schema, so filter field names are type-checked against the source entity's TFields at the schema literal.
  • No behavior change for associations that don't set additionalFieldFilters — the cascade load is equivalent to the previous single-equality load.

Example

// Two entity classes backed by the same `poly_children` table, each with a
// scope-specific constructor invariant. The scope filter in each association
// keeps the cascade load from trying to construct sibling-scope rows.
const scopeAChildEntityConfiguration = new EntityConfiguration<PolyChildFields, 'id'>({
  idField: 'id',
  tableName: 'poly_children',
  schema: {
    id: new UUIDField({ columnName: 'id', cache: true }),
    parent_id: new UUIDField({
      columnName: 'parent_id',
      cache: true,
      association: {
        associatedEntityClass: PolyParentEntity,
        edgeDeletionBehavior: EntityEdgeDeletionBehavior.CASCADE_DELETE,
        additionalFieldFilters: [{ fieldName: 'scope', fieldValue: 'A' }],
      },
    }),
    scope: new StringField({ columnName: 'scope' }),
  },
  databaseAdapterFlavor: 'postgres',
  cacheAdapterFlavor: 'redis',
});

Related

Test plan

Tests

Copy link
Copy Markdown
Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 15, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (9dda1bb) to head (5827cce).

Additional details and impacted files
@@            Coverage Diff             @@
##              main      #615    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files          110       110            
  Lines        17694     17901   +207     
  Branches       911      1541   +630     
==========================================
+ Hits         17694     17901   +207     
Flag Coverage Δ
integration 28.03% <7.43%> (-0.50%) ⬇️
unittest 94.65% <100.00%> (+0.06%) ⬆️

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

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…ilters

## Summary

Adds an optional `additionalFieldFilters` field on `EntityAssociationDefinition` that AND's extra field-equality conditions onto the cascade load when the source entity is deleted. This lets polymorphic child tables — multiple entity classes backed by one DB table and distinguished by scope-specific columns — register each class with a scope-disambiguating filter so cascade loads through one class never see rows that belong to a sibling class (which would otherwise trip the wrong-scope constructor invariant and abort the entire cascade).

## Motivation

For polymorphic tables where multiple entity classes share a table and each enforces a scope-specific constructor invariant (e.g. `TurtleJobRunEntity` requires `appId !== null`, `AccountTurtleJobRunEntity` requires `accountId !== null`), the parent-delete cascade currently loads every row matching the foreign key regardless of scope. Loading mixed-scope rows through one class throws on the wrong-scope rows and aborts the cascade. Consumers had to work around this with helper functions; this PR makes the scope filter expressible in the association config itself.

## Mechanism

- New `FieldEqualityCondition` types live in the base `@expo/entity` package (re-exported from `@expo/entity-database-adapter-knex` for back-compat).
- New `fetchManyByFieldEqualityConjunctionAsync` on the base `EntityDatabaseAdapter` with a default impl that uses single-value operands for the SQL WHERE and applies multi-value operands as an in-memory filter. The knex adapter overrides it for native SQL conjunction filtering (with proper `IS NULL` semantics).
- New `loadManyByFieldEqualityConjunctionAsync` on both `AuthorizationResultBasedEntityLoader` and `EnforcingEntityLoader`.
- The cascade in `AuthorizationResultBasedEntityMutator.processEntityDeletionForInboundEdgesAsync` builds `[{ primary }, ...association.additionalFieldFilters]` and calls the new loader method. All four `EntityEdgeDeletionBehavior` branches benefit automatically.
- `additionalFieldFilters` is typed via a new `TSourceFields` generic threaded through `EntityFieldDefinition`, its options, and `EntityConfiguration.schema`, so filter field names are type-checked against the source entity's `TFields` at the schema literal.
- No behavior change for associations that don't set `additionalFieldFilters` — the cascade load is equivalent to the previous single-equality load.

## Related

- Original consumer motivation: expo/universe#27183
- Prototype helper in consumer code: expo/universe#27406

## Test plan

- [x] `yarn tsc`
- [x] `yarn lint`
- [x] `yarn test` — 735 unit tests, including 4 new in `EntityEdgesAdditionalFieldFilters-test.ts` covering: no-filter regression, polymorphic scope filters, multi-row cascade, no-op filter
- [x] `yarn integration` — 123 integration tests, including 2 new in `EntityEdgesAdditionalFieldFiltersIntegration-test.ts` exercising the native SQL conjunction path against Postgres
@szdziedzic szdziedzic force-pushed the szdziedzic/entity-additional-field-filters branch from ecd7856 to 5827cce Compare May 15, 2026 13:57
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.

1 participant