Skip to content

feat(entity): support polymorphic-table scoping via EntityConfiguration.inherentFilters#615

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

feat(entity): support polymorphic-table scoping via EntityConfiguration.inherentFilters#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 inherentFilters field on EntityConfiguration that AND's extra field-equality conditions into every fetch against the entity's table. Useful for polymorphic tables where multiple entity classes share a row layout but each represents a distinct scope (e.g., a discriminator column or a class-specific non-null column). Each class registers its own scope-disambiguating filter, and the framework guarantees that wrong-scope rows are invisible to that class on every load path — loadByID, loadByField, conjunction loads, cascade deletes — rather than tripping a constructor-level invariant.

Motivation

Polymorphic tables today require constructor-level invariants to discriminate scope. That works for the happy path but fails the moment any code loads a row of the wrong scope through the wrong class — including, notably, the inbound-edge cascade delete loop, which loads every child row matching the foreign key regardless of scope. The result is that a parent-delete cascade involving a polymorphic table throws on construction of the wrong-scope rows and aborts mid-cascade. Consumers have been working around this with bespoke helpers; this PR lifts the fix into the framework.

An earlier iteration of this PR scoped the filter to inbound-edge associations only. Per review feedback, the right place for a polymorphic invariant is the entity configuration itself — there is no situation in which one of these entities should load its rows without the filter — so this version makes inherentFilters always-applied.

Mechanism

  • New FieldEqualityCondition types in the base @expo/entity package.
  • New EntityConfiguration.inherentFilters field; precomputed inherentFiltersCacheKeyComponent derived from a deterministic serialization of the filters.
  • EntityDatabaseAdapter.fetchManyWhereAsync / fetchOneWhereAsync route through fetchManyByFieldEqualityConjunctionAsync for single-field keys when inherent filters are present, so adapters with native conjunction support (knex) push the filters all the way down to SQL; composite-key loads apply them as an in-memory post-filter.
  • fetchManyByFieldEqualityConjunctionAsync (base + knex override) AND's inherent filters into the operand list.
  • SingleFieldHolder and CompositeFieldHolder include inherentFiltersCacheKeyComponent in their cache-key parts so all cache adapters (stub, local-memory, redis) automatically scope cache namespaces per inherent-filter set without per-adapter changes.
  • EntityCompanionProvider.tableDataCoordinatorMap is now keyed by EntityConfiguration identity rather than tableName, so two configurations sharing a table but with different inherent filters get separate coordinators (and separate database/cache adapter instances). Two entity classes sharing one configuration still share a coordinator (the existing TwoEntitySameTableDisjointRows pattern).
  • New loadManyByFieldEqualityConjunctionAsync on both AuthorizationResultBasedEntityLoader and EnforcingEntityLoader — useful in its own right, and the inherent-filter plumbing routes through it.

Example

const scopeAChildEntityConfiguration = new EntityConfiguration<PolyChildFields, 'id'>({
  idField: 'id',
  tableName: 'poly_children',
  inherentFilters: [{ fieldName: 'scope', fieldValue: 'A' }],
  schema: {
    id: new UUIDField({ columnName: 'id', cache: true }),
    parent_id: new UUIDField({
      columnName: 'parent_id',
      association: {
        associatedEntityClass: PolyParentEntity,
        edgeDeletionBehavior: EntityEdgeDeletionBehavior.CASCADE_DELETE,
      },
    }),
    scope: new StringField({ columnName: 'scope' }),
  },
  databaseAdapterFlavor: 'postgres',
  cacheAdapterFlavor: 'redis',
});

A loadByID of a scope-B row through ScopeAChildEntity returns null. A cascade delete of the parent only deletes scope-A children through ScopeAChildEntity and scope-B children through ScopeBChildEntity.

Related

Test plan

  • yarn tsc
  • yarn lint
  • yarn test — 741 unit tests, including 4 new in EntityInherentFilters-test.ts covering: scoped loadByID (wrong-scope returns null), scoped loadByField, cascade isolation, and no-op cascade.
  • yarn integration — 123 integration tests, including 2 new in EntityInherentFiltersIntegration-test.ts exercising the native SQL conjunction path plus Redis cache key scoping against real Postgres + Redis.

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 (0152777).

Additional details and impacted files
@@            Coverage Diff             @@
##              main      #615    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files          110       110            
  Lines        17694     18002   +308     
  Branches       911       944    +33     
==========================================
+ Hits         17694     18002   +308     
Flag Coverage Δ
integration 27.88% <6.97%> (-0.65%) ⬇️
unittest 94.68% <100.00%> (+0.09%) ⬆️

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.

@szdziedzic szdziedzic force-pushed the szdziedzic/entity-additional-field-filters branch 3 times, most recently from c7cf4b2 to ce1dd94 Compare May 19, 2026 09:31
@szdziedzic szdziedzic changed the title feat(entity): support polymorphic-table cascades via additionalFieldFilters feat(entity): support polymorphic-table scoping via EntityConfiguration.inherentFilters May 19, 2026
@szdziedzic szdziedzic force-pushed the szdziedzic/entity-additional-field-filters branch from ce1dd94 to 0eece4d Compare May 19, 2026 10:09
…on.inherentFilters

## Summary

Adds an optional `inherentFilters` field on `EntityConfiguration` that AND's extra
field-equality conditions into every fetch against the entity's table. Useful for
polymorphic tables where multiple entity classes share a row layout but each represents
a distinct scope (e.g., a discriminator column or a class-specific non-null column).
Each class registers its own scope-disambiguating filter, and the framework guarantees
that wrong-scope rows are invisible to that class on every load path — `loadByID`,
`loadByField`, conjunction loads, cascade deletes — rather than tripping a
constructor-level invariant.

## Motivation

Polymorphic tables today require constructor-level invariants to discriminate scope.
That works for the happy path but fails the moment any code loads a row of the wrong
scope through the wrong class — including, notably, the inbound-edge cascade delete
loop, which loads every child row matching the foreign key regardless of scope. The
result is that a parent-delete cascade involving a polymorphic table throws on
construction of the wrong-scope rows and aborts mid-cascade. Consumers have been
working around this with bespoke helpers; this PR lifts the fix into the framework.

The earlier iteration of this PR scoped the filter to inbound-edge associations only.
Per review feedback, the right place for a polymorphic invariant is the entity
configuration itself — there is no situation in which one of these entities should load
its rows without the filter — so this version makes `inherentFilters` always-applied.

## Mechanism

- New `FieldEqualityCondition` types in the base `@expo/entity` package.
- New `EntityConfiguration.inherentFilters` field; precomputed
  `inherentFiltersCacheKeyComponent` derived from a deterministic serialization of the
  filters.
- `EntityDatabaseAdapter.fetchManyWhereAsync` / `fetchOneWhereAsync` route through
  `fetchManyByFieldEqualityConjunctionAsync` for single-field keys when inherent filters
  are present, so adapters with native conjunction support (knex) push the filters all
  the way down to SQL; composite-key loads apply them as an in-memory post-filter.
- `fetchManyByFieldEqualityConjunctionAsync` (base + knex override) AND's inherent
  filters into the operand list.
- `SingleFieldHolder` and `CompositeFieldHolder` include
  `inherentFiltersCacheKeyComponent` in their cache-key parts so all cache adapters
  (stub, local-memory, redis) automatically scope cache namespaces per inherent-filter
  set without per-adapter changes.
- `EntityCompanionProvider.tableDataCoordinatorMap` is now keyed by `EntityConfiguration`
  identity rather than `tableName`, so two configurations sharing a table but with
  different inherent filters get separate coordinators (and separate database/cache
  adapter instances). Two entity classes sharing one configuration still share a
  coordinator (the existing `TwoEntitySameTableDisjointRows` pattern).
- New `loadManyByFieldEqualityConjunctionAsync` on both `AuthorizationResultBasedEntityLoader`
  and `EnforcingEntityLoader` — useful in its own right, and the cascade-load /
  inherent-filter plumbing routes through it.

## Example

```ts
const scopeAChildEntityConfiguration = new EntityConfiguration<PolyChildFields, 'id'>({
  idField: 'id',
  tableName: 'poly_children',
  inherentFilters: [{ fieldName: 'scope', fieldValue: 'A' }],
  schema: {
    id: new UUIDField({ columnName: 'id', cache: true }),
    parent_id: new UUIDField({
      columnName: 'parent_id',
      association: {
        associatedEntityClass: PolyParentEntity,
        edgeDeletionBehavior: EntityEdgeDeletionBehavior.CASCADE_DELETE,
      },
    }),
    scope: new StringField({ columnName: 'scope' }),
  },
  databaseAdapterFlavor: 'postgres',
  cacheAdapterFlavor: 'redis',
});
```

A `loadByID` of a scope-B row through `ScopeAChildEntity` returns `null`. A cascade
delete of the parent only deletes scope-A children through `ScopeAChildEntity` and
scope-B children through `ScopeBChildEntity`.

## 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` — 741 unit tests, including 4 new in `EntityInherentFilters-test.ts`
  covering: scoped `loadByID` (wrong-scope returns null), scoped `loadByField`, cascade
  isolation, and no-op cascade.
- [x] `yarn integration` — 123 integration tests, including 2 new in
  `EntityInherentFiltersIntegration-test.ts` exercising the native SQL conjunction path
  plus Redis cache key scoping against real Postgres + Redis.
@szdziedzic szdziedzic force-pushed the szdziedzic/entity-additional-field-filters branch from 0eece4d to 0152777 Compare May 19, 2026 15:09
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