Skip to content

Commit c7cf4b2

Browse files
committed
feat(entity): support polymorphic-table cascades via additionalFieldFilters
## 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
1 parent 9dda1bb commit c7cf4b2

22 files changed

Lines changed: 1121 additions & 126 deletions

packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,15 @@ import type {
22
EntityConstructionUtils,
33
EntityPrivacyPolicy,
44
EntityQueryContext,
5+
FieldEqualityCondition,
56
IEntityMetricsAdapter,
67
ReadonlyEntity,
78
ViewerContext,
89
} from '@expo/entity';
10+
import { isSingleValueFieldEqualityCondition } from '@expo/entity';
911
import type { Result } from '@expo/results';
1012

11-
import type {
12-
FieldEqualityCondition,
13-
NullsOrdering,
14-
OrderByOrdering,
15-
} from './BasePostgresEntityDatabaseAdapter.ts';
16-
import { isSingleValueFieldEqualityCondition } from './BasePostgresEntityDatabaseAdapter.ts';
13+
import type { NullsOrdering, OrderByOrdering } from './BasePostgresEntityDatabaseAdapter.ts';
1714
import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder.ts';
1815
import type { PaginationStrategy } from './PaginationStrategy.ts';
1916
import type { SQLFragment } from './SQLOperator.ts';

packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts

Lines changed: 8 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,14 @@
1-
import type { EntityQueryContext } from '@expo/entity';
1+
import type { EntityQueryContext, FieldEqualityCondition } from '@expo/entity';
22
import {
33
EntityDatabaseAdapter,
44
getDatabaseFieldForEntityField,
5+
isSingleValueFieldEqualityCondition,
56
transformDatabaseObjectToFields,
67
} from '@expo/entity';
78
import type { Knex } from 'knex';
89

910
import type { SQLFragment } from './SQLOperator.ts';
1011

11-
/**
12-
* Equality operand that is used for selecting entities with a field with a single value.
13-
*/
14-
export interface SingleValueFieldEqualityCondition<
15-
TFields extends Record<string, any>,
16-
N extends keyof TFields = keyof TFields,
17-
> {
18-
fieldName: N;
19-
fieldValue: TFields[N];
20-
}
21-
22-
/**
23-
* Equality operand that is used for selecting entities with a field matching one of multiple values.
24-
*/
25-
export interface MultiValueFieldEqualityCondition<
26-
TFields extends Record<string, any>,
27-
N extends keyof TFields = keyof TFields,
28-
> {
29-
fieldName: N;
30-
fieldValues: readonly TFields[N][];
31-
}
32-
33-
/**
34-
* A single equality operand for use in a selection clause.
35-
* See EntityLoader.loadManyByFieldEqualityConjunctionAsync documentation for examples.
36-
*/
37-
export type FieldEqualityCondition<
38-
TFields extends Record<string, any>,
39-
N extends keyof TFields = keyof TFields,
40-
> = SingleValueFieldEqualityCondition<TFields, N> | MultiValueFieldEqualityCondition<TFields, N>;
41-
42-
export function isSingleValueFieldEqualityCondition<
43-
TFields extends Record<string, any>,
44-
N extends keyof TFields = keyof TFields,
45-
>(
46-
condition: FieldEqualityCondition<TFields, N>,
47-
): condition is SingleValueFieldEqualityCondition<TFields, N> {
48-
return (condition as SingleValueFieldEqualityCondition<TFields, N>).fieldValue !== undefined;
49-
}
50-
5112
export interface TableFieldSingleValueEqualityCondition {
5213
tableField: string;
5314
tableValue: any;
@@ -167,17 +128,19 @@ export abstract class BasePostgresEntityDatabaseAdapter<
167128
}
168129
/**
169130
* Fetch many objects matching the conjunction of where clauses constructed from
170-
* specified field equality operands.
131+
* specified field equality operands. Overrides the base entity adapter implementation
132+
* to push the conjunction filter all the way down to SQL.
171133
*
172134
* @param queryContext - query context with which to perform the fetch
173135
* @param fieldEqualityOperands - list of field equality where clause operand specifications
174-
* @param querySelectionModifiers - limit, offset, orderBy, and orderByRaw for the query
136+
* @param querySelectionModifiers - limit, offset, orderBy, and orderByRaw for the query.
137+
* Optional; when omitted the entire conjunction is returned in arbitrary order.
175138
* @returns array of objects matching the query
176139
*/
177-
async fetchManyByFieldEqualityConjunctionAsync<N extends keyof TFields>(
140+
override async fetchManyByFieldEqualityConjunctionAsync<N extends keyof TFields>(
178141
queryContext: EntityQueryContext,
179142
fieldEqualityOperands: readonly FieldEqualityCondition<TFields, N>[],
180-
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
143+
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields> = {},
181144
): Promise<readonly Readonly<TFields>[]> {
182145
const tableFieldSingleValueOperands: TableFieldSingleValueEqualityCondition[] = [];
183146
const tableFieldMultipleValueOperands: TableFieldMultiValueEqualityCondition[] = [];

packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
EntityConstructionUtils,
33
EntityPrivacyPolicy,
44
EntityQueryContext,
5+
FieldEqualityCondition,
56
IEntityMetricsAdapter,
67
ReadonlyEntity,
78
ViewerContext,
@@ -12,7 +13,6 @@ import type {
1213
EntityLoaderLoadPageArgs,
1314
EntityLoaderQuerySelectionModifiers,
1415
} from './AuthorizationResultBasedKnexEntityLoader.ts';
15-
import type { FieldEqualityCondition } from './BasePostgresEntityDatabaseAdapter.ts';
1616
import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder.ts';
1717
import type { SQLFragment } from './SQLOperator.ts';
1818
import type { Connection, EntityKnexDataManager } from './internal/EntityKnexDataManager.ts';

packages/entity-database-adapter-knex/src/EntityFields.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import { EntityFieldDefinition } from '@expo/entity';
33
/**
44
* EntityFieldDefinition for a Postres column with a JS JSON array type.
55
*/
6-
export class JSONArrayField<TRequireExplicitCache extends boolean> extends EntityFieldDefinition<
7-
any[],
8-
TRequireExplicitCache
9-
> {
6+
export class JSONArrayField<
7+
TRequireExplicitCache extends boolean,
8+
TSourceFields extends Record<string, any> = any,
9+
> extends EntityFieldDefinition<any[], TRequireExplicitCache, TSourceFields> {
1010
protected validateInputValueInternal(value: any[]): boolean {
1111
return Array.isArray(value);
1212
}
@@ -18,7 +18,8 @@ export class JSONArrayField<TRequireExplicitCache extends boolean> extends Entit
1818
*/
1919
export class MaybeJSONArrayField<
2020
TRequireExplicitCache extends boolean,
21-
> extends EntityFieldDefinition<any | any[], TRequireExplicitCache> {
21+
TSourceFields extends Record<string, any> = any,
22+
> extends EntityFieldDefinition<any | any[], TRequireExplicitCache, TSourceFields> {
2223
protected validateInputValueInternal(_value: any): boolean {
2324
return true;
2425
}
@@ -27,10 +28,10 @@ export class MaybeJSONArrayField<
2728
/**
2829
* EntityFieldDefinition for a Postgres BIGINT column.
2930
*/
30-
export class BigIntField<TRequireExplicitCache extends boolean> extends EntityFieldDefinition<
31-
string,
32-
TRequireExplicitCache
33-
> {
31+
export class BigIntField<
32+
TRequireExplicitCache extends boolean,
33+
TSourceFields extends Record<string, any> = any,
34+
> extends EntityFieldDefinition<string, TRequireExplicitCache, TSourceFields> {
3435
protected validateInputValueInternal(value: string): boolean {
3536
return typeof value === 'string';
3637
}

packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import type { EntityConfiguration, EntityQueryContext, IEntityMetricsAdapter } from '@expo/entity';
1+
import type {
2+
EntityConfiguration,
3+
EntityQueryContext,
4+
FieldEqualityCondition,
5+
IEntityMetricsAdapter,
6+
} from '@expo/entity';
27
import {
38
EntityDatabaseAdapterPaginationCursorInvalidError,
49
EntityMetricsLoadType,
@@ -10,7 +15,6 @@ import assert from 'assert';
1015

1116
import type {
1217
BasePostgresEntityDatabaseAdapter,
13-
FieldEqualityCondition,
1418
PostgresOrderByClause,
1519
PostgresQuerySelectionModifiers,
1620
} from '../BasePostgresEntityDatabaseAdapter.ts';
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { ViewerContext } from '@expo/entity';
2+
import type { GenericRedisCacheContext } from '@expo/entity-cache-adapter-redis';
3+
import { RedisCacheInvalidationStrategy } from '@expo/entity-cache-adapter-redis';
4+
import nullthrows from '@expo/nullthrows';
5+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals';
6+
import { Redis } from 'ioredis';
7+
import type { Knex } from 'knex';
8+
import knex from 'knex';
9+
import { URL } from 'url';
10+
11+
import { createFullIntegrationTestEntityCompanionProvider } from '../__testfixtures__/createFullIntegrationTestEntityCompanionProvider.ts';
12+
import PolyParentEntity from './entities/PolyParentEntity.ts';
13+
import ScopeAChildEntity from './entities/ScopeAChildEntity.ts';
14+
import ScopeBChildEntity from './entities/ScopeBChildEntity.ts';
15+
16+
async function createPostgresTablesAsync(knex: Knex): Promise<void> {
17+
await knex.schema.createTable('poly_parents', (table) => {
18+
table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
19+
});
20+
await knex.schema.createTable('poly_children', (table) => {
21+
table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
22+
table.uuid('parent_id').references('id').inTable('poly_parents');
23+
table.string('scope').notNullable();
24+
});
25+
}
26+
27+
async function truncatePostgresTablesAsync(knex: Knex): Promise<void> {
28+
await knex.raw('TRUNCATE TABLE poly_children, poly_parents CASCADE');
29+
}
30+
31+
async function dropPostgresTablesAsync(knex: Knex): Promise<void> {
32+
if (await knex.schema.hasTable('poly_children')) {
33+
await knex.schema.dropTable('poly_children');
34+
}
35+
if (await knex.schema.hasTable('poly_parents')) {
36+
await knex.schema.dropTable('poly_parents');
37+
}
38+
}
39+
40+
describe('EntityMutator.processEntityDeletionForInboundEdgesAsync with additionalFieldFilters', () => {
41+
let knexInstance: Knex;
42+
const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
43+
let genericRedisCacheContext: GenericRedisCacheContext;
44+
45+
beforeAll(async () => {
46+
knexInstance = knex({
47+
client: 'pg',
48+
connection: {
49+
user: nullthrows(process.env['PGUSER']),
50+
password: nullthrows(process.env['PGPASSWORD']),
51+
host: 'localhost',
52+
port: parseInt(nullthrows(process.env['PGPORT']), 10),
53+
database: nullthrows(process.env['PGDATABASE']),
54+
},
55+
});
56+
genericRedisCacheContext = {
57+
redisClient,
58+
makeKeyFn(...parts: string[]): string {
59+
const delimiter = ':';
60+
const escapedParts = parts.map((part) =>
61+
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
62+
);
63+
return escapedParts.join(delimiter);
64+
},
65+
cacheKeyPrefix: 'test-',
66+
ttlSecondsPositive: 86400,
67+
ttlSecondsNegative: 600,
68+
invalidationConfig: {
69+
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION,
70+
},
71+
};
72+
await createPostgresTablesAsync(knexInstance);
73+
});
74+
75+
beforeEach(async () => {
76+
await truncatePostgresTablesAsync(knexInstance);
77+
await redisClient.flushdb();
78+
});
79+
80+
afterAll(async () => {
81+
await dropPostgresTablesAsync(knexInstance);
82+
await knexInstance.destroy();
83+
redisClient.disconnect();
84+
});
85+
86+
it('cascade-deletes only rows matching the additionalFieldFilters of each association', async () => {
87+
const viewerContext = new ViewerContext(
88+
createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext),
89+
);
90+
91+
const parent = await PolyParentEntity.creator(viewerContext).createAsync();
92+
const scopeARow = await ScopeAChildEntity.creator(viewerContext)
93+
.setField('parent_id', parent.getID())
94+
.setField('scope', 'A')
95+
.createAsync();
96+
const scopeBRow = await ScopeBChildEntity.creator(viewerContext)
97+
.setField('parent_id', parent.getID())
98+
.setField('scope', 'B')
99+
.createAsync();
100+
101+
await expect(
102+
ScopeAChildEntity.loader(viewerContext).loadByIDNullableAsync(scopeARow.getID()),
103+
).resolves.not.toBeNull();
104+
await expect(
105+
ScopeBChildEntity.loader(viewerContext).loadByIDNullableAsync(scopeBRow.getID()),
106+
).resolves.not.toBeNull();
107+
108+
// Without per-class scope filters in the inbound-edge associations, the cascade would
109+
// load every scope-A and scope-B row through each class and trip the wrong-scope
110+
// constructor invariant. The additionalFieldFilters narrow each cascade load to the
111+
// single matching scope.
112+
await PolyParentEntity.deleter(parent).deleteAsync();
113+
114+
await expect(
115+
ScopeAChildEntity.loader(viewerContext).loadByIDNullableAsync(scopeARow.getID()),
116+
).resolves.toBeNull();
117+
await expect(
118+
ScopeBChildEntity.loader(viewerContext).loadByIDNullableAsync(scopeBRow.getID()),
119+
).resolves.toBeNull();
120+
});
121+
122+
it('is a no-op for an association whose filter matches no rows', async () => {
123+
const viewerContext = new ViewerContext(
124+
createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext),
125+
);
126+
127+
const parent = await PolyParentEntity.creator(viewerContext).createAsync();
128+
const scopeARow = await ScopeAChildEntity.creator(viewerContext)
129+
.setField('parent_id', parent.getID())
130+
.setField('scope', 'A')
131+
.createAsync();
132+
// No scope=B rows; the ScopeB association load returns zero rows.
133+
134+
await PolyParentEntity.deleter(parent).deleteAsync();
135+
136+
await expect(
137+
ScopeAChildEntity.loader(viewerContext).loadByIDNullableAsync(scopeARow.getID()),
138+
).resolves.toBeNull();
139+
});
140+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { EntityCompanionDefinition, ViewerContext } from '@expo/entity';
2+
import {
3+
AlwaysAllowPrivacyPolicyRule,
4+
Entity,
5+
EntityConfiguration,
6+
EntityPrivacyPolicy,
7+
UUIDField,
8+
} from '@expo/entity';
9+
10+
import ScopeAChildEntity from './ScopeAChildEntity.ts';
11+
import ScopeBChildEntity from './ScopeBChildEntity.ts';
12+
13+
interface PolyParentFields {
14+
id: string;
15+
}
16+
17+
class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<any, 'id', ViewerContext, any, any> {
18+
protected override readonly readRules = [
19+
new AlwaysAllowPrivacyPolicyRule<any, 'id', ViewerContext, any, any>(),
20+
];
21+
protected override readonly createRules = [
22+
new AlwaysAllowPrivacyPolicyRule<any, 'id', ViewerContext, any, any>(),
23+
];
24+
protected override readonly updateRules = [
25+
new AlwaysAllowPrivacyPolicyRule<any, 'id', ViewerContext, any, any>(),
26+
];
27+
protected override readonly deleteRules = [
28+
new AlwaysAllowPrivacyPolicyRule<any, 'id', ViewerContext, any, any>(),
29+
];
30+
}
31+
32+
export default class PolyParentEntity extends Entity<PolyParentFields, 'id', ViewerContext> {
33+
static defineCompanionDefinition(): EntityCompanionDefinition<
34+
PolyParentFields,
35+
'id',
36+
ViewerContext,
37+
PolyParentEntity,
38+
TestEntityPrivacyPolicy
39+
> {
40+
return {
41+
entityClass: PolyParentEntity,
42+
entityConfiguration: new EntityConfiguration<PolyParentFields, 'id'>({
43+
idField: 'id',
44+
tableName: 'poly_parents',
45+
inboundEdges: [ScopeAChildEntity, ScopeBChildEntity],
46+
schema: {
47+
id: new UUIDField({
48+
columnName: 'id',
49+
cache: true,
50+
}),
51+
},
52+
databaseAdapterFlavor: 'postgres',
53+
cacheAdapterFlavor: 'redis',
54+
}),
55+
privacyPolicyClass: TestEntityPrivacyPolicy,
56+
};
57+
}
58+
}

0 commit comments

Comments
 (0)