diff --git a/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts b/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts index 970a1bf196..8352c8bd0e 100644 --- a/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts +++ b/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts @@ -2,18 +2,15 @@ import type { EntityConstructionUtils, EntityPrivacyPolicy, EntityQueryContext, + FieldEqualityCondition, IEntityMetricsAdapter, ReadonlyEntity, ViewerContext, } from '@expo/entity'; +import { isSingleValueFieldEqualityCondition } from '@expo/entity'; import type { Result } from '@expo/results'; -import type { - FieldEqualityCondition, - NullsOrdering, - OrderByOrdering, -} from './BasePostgresEntityDatabaseAdapter.ts'; -import { isSingleValueFieldEqualityCondition } from './BasePostgresEntityDatabaseAdapter.ts'; +import type { NullsOrdering, OrderByOrdering } from './BasePostgresEntityDatabaseAdapter.ts'; import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder.ts'; import type { PaginationStrategy } from './PaginationStrategy.ts'; import type { SQLFragment } from './SQLOperator.ts'; diff --git a/packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts b/packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts index 76645de364..6328d06652 100644 --- a/packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts +++ b/packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts @@ -1,53 +1,14 @@ -import type { EntityQueryContext } from '@expo/entity'; +import type { EntityQueryContext, FieldEqualityCondition } from '@expo/entity'; import { EntityDatabaseAdapter, getDatabaseFieldForEntityField, + isSingleValueFieldEqualityCondition, transformDatabaseObjectToFields, } from '@expo/entity'; import type { Knex } from 'knex'; import type { SQLFragment } from './SQLOperator.ts'; -/** - * Equality operand that is used for selecting entities with a field with a single value. - */ -export interface SingleValueFieldEqualityCondition< - TFields extends Record, - N extends keyof TFields = keyof TFields, -> { - fieldName: N; - fieldValue: TFields[N]; -} - -/** - * Equality operand that is used for selecting entities with a field matching one of multiple values. - */ -export interface MultiValueFieldEqualityCondition< - TFields extends Record, - N extends keyof TFields = keyof TFields, -> { - fieldName: N; - fieldValues: readonly TFields[N][]; -} - -/** - * A single equality operand for use in a selection clause. - * See EntityLoader.loadManyByFieldEqualityConjunctionAsync documentation for examples. - */ -export type FieldEqualityCondition< - TFields extends Record, - N extends keyof TFields = keyof TFields, -> = SingleValueFieldEqualityCondition | MultiValueFieldEqualityCondition; - -export function isSingleValueFieldEqualityCondition< - TFields extends Record, - N extends keyof TFields = keyof TFields, ->( - condition: FieldEqualityCondition, -): condition is SingleValueFieldEqualityCondition { - return (condition as SingleValueFieldEqualityCondition).fieldValue !== undefined; -} - export interface TableFieldSingleValueEqualityCondition { tableField: string; tableValue: any; @@ -167,21 +128,31 @@ export abstract class BasePostgresEntityDatabaseAdapter< } /** * Fetch many objects matching the conjunction of where clauses constructed from - * specified field equality operands. + * specified field equality operands. Overrides the base entity adapter implementation + * to push the conjunction filter all the way down to SQL. * * @param queryContext - query context with which to perform the fetch * @param fieldEqualityOperands - list of field equality where clause operand specifications - * @param querySelectionModifiers - limit, offset, orderBy, and orderByRaw for the query + * @param querySelectionModifiers - limit, offset, orderBy, and orderByRaw for the query. + * Optional; when omitted the entire conjunction is returned in arbitrary order. * @returns array of objects matching the query */ - async fetchManyByFieldEqualityConjunctionAsync( + override async fetchManyByFieldEqualityConjunctionAsync( queryContext: EntityQueryContext, fieldEqualityOperands: readonly FieldEqualityCondition[], - querySelectionModifiers: PostgresQuerySelectionModifiers, + querySelectionModifiers: PostgresQuerySelectionModifiers = {}, ): Promise[]> { + const combinedOperands: readonly FieldEqualityCondition[] = [ + ...fieldEqualityOperands, + ...(this.entityConfiguration.inherentFilters as readonly FieldEqualityCondition< + TFields, + N + >[]), + ]; + const tableFieldSingleValueOperands: TableFieldSingleValueEqualityCondition[] = []; const tableFieldMultipleValueOperands: TableFieldMultiValueEqualityCondition[] = []; - for (const operand of fieldEqualityOperands) { + for (const operand of combinedOperands) { if (isSingleValueFieldEqualityCondition(operand)) { tableFieldSingleValueOperands.push({ tableField: getDatabaseFieldForEntityField(this.entityConfiguration, operand.fieldName), diff --git a/packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts b/packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts index 43d875a66f..67515728f9 100644 --- a/packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts +++ b/packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts @@ -2,6 +2,7 @@ import type { EntityConstructionUtils, EntityPrivacyPolicy, EntityQueryContext, + FieldEqualityCondition, IEntityMetricsAdapter, ReadonlyEntity, ViewerContext, @@ -12,7 +13,6 @@ import type { EntityLoaderLoadPageArgs, EntityLoaderQuerySelectionModifiers, } from './AuthorizationResultBasedKnexEntityLoader.ts'; -import type { FieldEqualityCondition } from './BasePostgresEntityDatabaseAdapter.ts'; import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder.ts'; import type { SQLFragment } from './SQLOperator.ts'; import type { Connection, EntityKnexDataManager } from './internal/EntityKnexDataManager.ts'; diff --git a/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts b/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts index 87e772bb6c..8895766e82 100644 --- a/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts +++ b/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts @@ -1,4 +1,9 @@ -import type { EntityConfiguration, EntityQueryContext, IEntityMetricsAdapter } from '@expo/entity'; +import type { + EntityConfiguration, + EntityQueryContext, + FieldEqualityCondition, + IEntityMetricsAdapter, +} from '@expo/entity'; import { EntityDatabaseAdapterPaginationCursorInvalidError, EntityMetricsLoadType, @@ -10,7 +15,6 @@ import assert from 'assert'; import type { BasePostgresEntityDatabaseAdapter, - FieldEqualityCondition, PostgresOrderByClause, PostgresQuerySelectionModifiers, } from '../BasePostgresEntityDatabaseAdapter.ts'; diff --git a/packages/entity-full-integration-tests/src/__integration-tests__/EntityInherentFiltersIntegration-test.ts b/packages/entity-full-integration-tests/src/__integration-tests__/EntityInherentFiltersIntegration-test.ts new file mode 100644 index 0000000000..1d5e16d93b --- /dev/null +++ b/packages/entity-full-integration-tests/src/__integration-tests__/EntityInherentFiltersIntegration-test.ts @@ -0,0 +1,142 @@ +import { ViewerContext } from '@expo/entity'; +import type { GenericRedisCacheContext } from '@expo/entity-cache-adapter-redis'; +import { RedisCacheInvalidationStrategy } from '@expo/entity-cache-adapter-redis'; +import nullthrows from '@expo/nullthrows'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'; +import { Redis } from 'ioredis'; +import type { Knex } from 'knex'; +import knex from 'knex'; +import { URL } from 'url'; + +import { createFullIntegrationTestEntityCompanionProvider } from '../__testfixtures__/createFullIntegrationTestEntityCompanionProvider.ts'; +import PolyParentEntity from './entities/PolyParentEntity.ts'; +import ScopeAChildEntity from './entities/ScopeAChildEntity.ts'; +import ScopeBChildEntity from './entities/ScopeBChildEntity.ts'; + +async function createPostgresTablesAsync(knex: Knex): Promise { + await knex.schema.createTable('poly_parents', (table) => { + table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary(); + }); + await knex.schema.createTable('poly_children', (table) => { + table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary(); + table.uuid('parent_id').references('id').inTable('poly_parents'); + table.string('scope').notNullable(); + }); +} + +async function truncatePostgresTablesAsync(knex: Knex): Promise { + await knex.raw('TRUNCATE TABLE poly_children, poly_parents CASCADE'); +} + +async function dropPostgresTablesAsync(knex: Knex): Promise { + if (await knex.schema.hasTable('poly_children')) { + await knex.schema.dropTable('poly_children'); + } + if (await knex.schema.hasTable('poly_parents')) { + await knex.schema.dropTable('poly_parents'); + } +} + +describe('EntityConfiguration.inherentFilters (Postgres + Redis)', () => { + let knexInstance: Knex; + const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString()); + let genericRedisCacheContext: GenericRedisCacheContext; + + beforeAll(async () => { + knexInstance = knex({ + client: 'pg', + connection: { + user: nullthrows(process.env['PGUSER']), + password: nullthrows(process.env['PGPASSWORD']), + host: 'localhost', + port: parseInt(nullthrows(process.env['PGPORT']), 10), + database: nullthrows(process.env['PGDATABASE']), + }, + }); + genericRedisCacheContext = { + redisClient, + makeKeyFn(...parts: string[]): string { + const delimiter = ':'; + const escapedParts = parts.map((part) => + part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`), + ); + return escapedParts.join(delimiter); + }, + cacheKeyPrefix: 'test-', + ttlSecondsPositive: 86400, + ttlSecondsNegative: 600, + invalidationConfig: { + invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION, + }, + }; + await createPostgresTablesAsync(knexInstance); + }); + + beforeEach(async () => { + await truncatePostgresTablesAsync(knexInstance); + await redisClient.flushdb(); + }); + + afterAll(async () => { + await dropPostgresTablesAsync(knexInstance); + await knexInstance.destroy(); + redisClient.disconnect(); + }); + + it('loads only rows that match the inherent filter', async () => { + const viewerContext = new ViewerContext( + createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext), + ); + + const parent = await PolyParentEntity.creator(viewerContext).createAsync(); + const scopeARow = await ScopeAChildEntity.creator(viewerContext) + .setField('parent_id', parent.getID()) + .setField('scope', 'A') + .createAsync(); + const scopeBRow = await ScopeBChildEntity.creator(viewerContext) + .setField('parent_id', parent.getID()) + .setField('scope', 'B') + .createAsync(); + + await expect( + ScopeAChildEntity.loader(viewerContext).loadByIDNullableAsync(scopeARow.getID()), + ).resolves.not.toBeNull(); + await expect( + ScopeBChildEntity.loader(viewerContext).loadByIDNullableAsync(scopeBRow.getID()), + ).resolves.not.toBeNull(); + + // A wrong-scope id load returns null, not a constructor invariant throw — the SQL + // WHERE clause AND's the inherent filter, so the row is invisible to the wrong class. + await expect( + ScopeAChildEntity.loader(viewerContext).loadByIDNullableAsync(scopeBRow.getID()), + ).resolves.toBeNull(); + await expect( + ScopeBChildEntity.loader(viewerContext).loadByIDNullableAsync(scopeARow.getID()), + ).resolves.toBeNull(); + }); + + it("cascade-delete via inboundEdges only deletes rows matching each class's inherent filter", async () => { + const viewerContext = new ViewerContext( + createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext), + ); + + const parent = await PolyParentEntity.creator(viewerContext).createAsync(); + const scopeARow = await ScopeAChildEntity.creator(viewerContext) + .setField('parent_id', parent.getID()) + .setField('scope', 'A') + .createAsync(); + const scopeBRow = await ScopeBChildEntity.creator(viewerContext) + .setField('parent_id', parent.getID()) + .setField('scope', 'B') + .createAsync(); + + await PolyParentEntity.deleter(parent).deleteAsync(); + + await expect( + ScopeAChildEntity.loader(viewerContext).loadByIDNullableAsync(scopeARow.getID()), + ).resolves.toBeNull(); + await expect( + ScopeBChildEntity.loader(viewerContext).loadByIDNullableAsync(scopeBRow.getID()), + ).resolves.toBeNull(); + }); +}); diff --git a/packages/entity-full-integration-tests/src/__integration-tests__/entities/PolyParentEntity.ts b/packages/entity-full-integration-tests/src/__integration-tests__/entities/PolyParentEntity.ts new file mode 100644 index 0000000000..7b47e58fd5 --- /dev/null +++ b/packages/entity-full-integration-tests/src/__integration-tests__/entities/PolyParentEntity.ts @@ -0,0 +1,55 @@ +import type { EntityCompanionDefinition, ViewerContext } from '@expo/entity'; +import { + AlwaysAllowPrivacyPolicyRule, + Entity, + EntityConfiguration, + EntityPrivacyPolicy, + UUIDField, +} from '@expo/entity'; + +import ScopeAChildEntity from './ScopeAChildEntity.ts'; +import ScopeBChildEntity from './ScopeBChildEntity.ts'; + +interface PolyParentFields { + id: string; +} + +class TestEntityPrivacyPolicy extends EntityPrivacyPolicy { + protected override readonly readRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly createRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly updateRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly deleteRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; +} + +export default class PolyParentEntity extends Entity { + static defineCompanionDefinition(): EntityCompanionDefinition< + PolyParentFields, + 'id', + ViewerContext, + PolyParentEntity, + TestEntityPrivacyPolicy + > { + return { + entityClass: PolyParentEntity, + entityConfiguration: new EntityConfiguration({ + idField: 'id', + tableName: 'poly_parents', + inboundEdges: [ScopeAChildEntity, ScopeBChildEntity], + schema: { + id: new UUIDField({ columnName: 'id', cache: true }), + }, + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', + }), + privacyPolicyClass: TestEntityPrivacyPolicy, + }; + } +} diff --git a/packages/entity-full-integration-tests/src/__integration-tests__/entities/ScopeAChildEntity.ts b/packages/entity-full-integration-tests/src/__integration-tests__/entities/ScopeAChildEntity.ts new file mode 100644 index 0000000000..c4d9a18112 --- /dev/null +++ b/packages/entity-full-integration-tests/src/__integration-tests__/entities/ScopeAChildEntity.ts @@ -0,0 +1,80 @@ +import type { EntityCompanionDefinition, ViewerContext } from '@expo/entity'; +import { + AlwaysAllowPrivacyPolicyRule, + Entity, + EntityConfiguration, + EntityEdgeDeletionBehavior, + EntityPrivacyPolicy, + StringField, + UUIDField, +} from '@expo/entity'; + +import PolyParentEntity from './PolyParentEntity.ts'; + +interface PolyChildFields { + id: string; + parent_id: string; + scope: 'A' | 'B'; +} + +class TestEntityPrivacyPolicy extends EntityPrivacyPolicy { + protected override readonly readRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly createRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly updateRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly deleteRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; +} + +export default class ScopeAChildEntity extends Entity { + constructor(constructorParams: { + viewerContext: ViewerContext; + id: string; + databaseFields: Readonly; + selectedFields: Readonly>; + }) { + if (constructorParams.databaseFields.scope !== 'A') { + throw new Error( + `ScopeAChildEntity requires scope='A', got '${constructorParams.databaseFields.scope}'`, + ); + } + super(constructorParams); + } + + static defineCompanionDefinition(): EntityCompanionDefinition< + PolyChildFields, + 'id', + ViewerContext, + ScopeAChildEntity, + TestEntityPrivacyPolicy + > { + return { + entityClass: ScopeAChildEntity, + entityConfiguration: new EntityConfiguration({ + 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', + }), + privacyPolicyClass: TestEntityPrivacyPolicy, + }; + } +} diff --git a/packages/entity-full-integration-tests/src/__integration-tests__/entities/ScopeBChildEntity.ts b/packages/entity-full-integration-tests/src/__integration-tests__/entities/ScopeBChildEntity.ts new file mode 100644 index 0000000000..f3f20b50e8 --- /dev/null +++ b/packages/entity-full-integration-tests/src/__integration-tests__/entities/ScopeBChildEntity.ts @@ -0,0 +1,80 @@ +import type { EntityCompanionDefinition, ViewerContext } from '@expo/entity'; +import { + AlwaysAllowPrivacyPolicyRule, + Entity, + EntityConfiguration, + EntityEdgeDeletionBehavior, + EntityPrivacyPolicy, + StringField, + UUIDField, +} from '@expo/entity'; + +import PolyParentEntity from './PolyParentEntity.ts'; + +interface PolyChildFields { + id: string; + parent_id: string; + scope: 'A' | 'B'; +} + +class TestEntityPrivacyPolicy extends EntityPrivacyPolicy { + protected override readonly readRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly createRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly updateRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly deleteRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; +} + +export default class ScopeBChildEntity extends Entity { + constructor(constructorParams: { + viewerContext: ViewerContext; + id: string; + databaseFields: Readonly; + selectedFields: Readonly>; + }) { + if (constructorParams.databaseFields.scope !== 'B') { + throw new Error( + `ScopeBChildEntity requires scope='B', got '${constructorParams.databaseFields.scope}'`, + ); + } + super(constructorParams); + } + + static defineCompanionDefinition(): EntityCompanionDefinition< + PolyChildFields, + 'id', + ViewerContext, + ScopeBChildEntity, + TestEntityPrivacyPolicy + > { + return { + entityClass: ScopeBChildEntity, + entityConfiguration: new EntityConfiguration({ + idField: 'id', + tableName: 'poly_children', + inherentFilters: [{ fieldName: 'scope', fieldValue: 'B' }], + 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', + }), + privacyPolicyClass: TestEntityPrivacyPolicy, + }; + } +} diff --git a/packages/entity/src/AuthorizationResultBasedEntityLoader.ts b/packages/entity/src/AuthorizationResultBasedEntityLoader.ts index 3e31474035..4a3b9538df 100644 --- a/packages/entity/src/AuthorizationResultBasedEntityLoader.ts +++ b/packages/entity/src/AuthorizationResultBasedEntityLoader.ts @@ -10,6 +10,8 @@ import type { EntityConfiguration, } from './EntityConfiguration.ts'; import type { EntityConstructionUtils } from './EntityConstructionUtils.ts'; +import type { FieldEqualityCondition } from './EntityDatabaseAdapter.ts'; +import { isSingleValueFieldEqualityCondition } from './EntityDatabaseAdapter.ts'; import type { EntityPrivacyPolicy } from './EntityPrivacyPolicy.ts'; import type { EntityQueryContext } from './EntityQueryContext.ts'; import type { ReadonlyEntity } from './ReadonlyEntity.ts'; @@ -136,6 +138,37 @@ export class AuthorizationResultBasedEntityLoader< return entityResultsForFieldValue; } + /** + * Load entities matching the conjunction of field equality operands. + * + * Each operand asserts either that a field equals a single value + * (`{ fieldName, fieldValue }`) or that a field equals one of multiple values + * (`{ fieldName, fieldValues }`). All operands are AND'd together. + * + * Unlike {@link loadManyByFieldEqualingAsync}, this load goes directly to the database + * adapter; results are not memoized in the dataloader and the entity cache is not consulted. + * + * @param fieldEqualityOperands - list of field equality where-clause operands AND'd together + * @returns array of entity results matching the conjunction, where result error can be + * UnauthorizedError or an entity construction error + */ + async loadManyByFieldEqualityConjunctionAsync>( + fieldEqualityOperands: readonly FieldEqualityCondition[], + ): Promise[]> { + for (const operand of fieldEqualityOperands) { + const fieldValues = isSingleValueFieldEqualityCondition(operand) + ? [operand.fieldValue] + : operand.fieldValues; + this.constructionUtils.validateFieldAndValues(operand.fieldName, fieldValues); + } + + const fieldObjects = await this.dataManager.loadManyByFieldEqualityConjunctionAsync( + this.queryContext, + fieldEqualityOperands, + ); + return await this.constructionUtils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects); + } + /** * Load one entity where fieldName equals fieldValue, or null if no entity exists matching the condition. * Not cached or coalesced, and not guaranteed to be deterministic if multiple entities match the condition. diff --git a/packages/entity/src/EnforcingEntityLoader.ts b/packages/entity/src/EnforcingEntityLoader.ts index 39043b7685..4ba8bc2f97 100644 --- a/packages/entity/src/EnforcingEntityLoader.ts +++ b/packages/entity/src/EnforcingEntityLoader.ts @@ -1,5 +1,6 @@ import type { AuthorizationResultBasedEntityLoader } from './AuthorizationResultBasedEntityLoader.ts'; import type { EntityCompositeField, EntityCompositeFieldValue } from './EntityConfiguration.ts'; +import type { FieldEqualityCondition } from './EntityDatabaseAdapter.ts'; import type { EntityPrivacyPolicy } from './EntityPrivacyPolicy.ts'; import type { ReadonlyEntity } from './ReadonlyEntity.ts'; import type { ViewerContext } from './ViewerContext.ts'; @@ -104,6 +105,20 @@ export class EnforcingEntityLoader< return entityResults.map((result) => result.enforceValue()); } + /** + * Load many entities matching the conjunction of field equality operands. + * @param fieldEqualityOperands - field equality where-clause operands AND'd together + * @returns array of entities matching the conjunction + * @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities + */ + async loadManyByFieldEqualityConjunctionAsync>( + fieldEqualityOperands: readonly FieldEqualityCondition[], + ): Promise { + const entityResults = + await this.entityLoader.loadManyByFieldEqualityConjunctionAsync(fieldEqualityOperands); + return entityResults.map((result) => result.enforceValue()); + } + /** * Load many entities where compositeField equals compositeFieldValue. * @param compositeField - composite field being queried diff --git a/packages/entity/src/EntityCompanionProvider.ts b/packages/entity/src/EntityCompanionProvider.ts index b032da8385..6d257f923c 100644 --- a/packages/entity/src/EntityCompanionProvider.ts +++ b/packages/entity/src/EntityCompanionProvider.ts @@ -132,8 +132,15 @@ export class EntityCompanionProvider { > = new Map(); private readonly companionMap: Map> = new Map(); - private readonly tableDataCoordinatorMap: Map> = - new Map(); + // Keyed by EntityConfiguration object identity (not tableName), so two entity classes + // backed by the same table but distinguished by their EntityConfiguration's inherent + // filters get separate coordinators — and thus separate database/cache adapters and + // separate dataloader caches scoped to their own inherent filters. Two entity classes + // that share an EntityConfiguration still share a coordinator. + private readonly tableDataCoordinatorMap: Map< + EntityConfiguration, + EntityTableDataCoordinator + > = new Map(); /** * Instantiate an Entity framework. @@ -228,7 +235,7 @@ export class EntityCompanionProvider { entityConfiguration: EntityConfiguration, entityClassName: string, ): EntityTableDataCoordinator { - return computeIfAbsent(this.tableDataCoordinatorMap, entityConfiguration.tableName, () => { + return computeIfAbsent(this.tableDataCoordinatorMap, entityConfiguration, () => { const entityDatabaseAdapterFlavor = this.databaseAdapterFlavors.get( entityConfiguration.databaseAdapterFlavor, ); diff --git a/packages/entity/src/EntityConfiguration.ts b/packages/entity/src/EntityConfiguration.ts index af90eab4c9..880cec00ca 100644 --- a/packages/entity/src/EntityConfiguration.ts +++ b/packages/entity/src/EntityConfiguration.ts @@ -2,6 +2,7 @@ import invariant from 'invariant'; import type { IEntityClass } from './Entity.ts'; import type { CacheAdapterFlavor, DatabaseAdapterFlavor } from './EntityCompanionProvider.ts'; +import type { FieldEqualityCondition } from './EntityDatabaseAdapter.ts'; import { RESERVED_ENTITY_COUNT_QUERY_ALIAS } from './EntityDatabaseAdapter.ts'; import type { EntityFieldDefinition } from './EntityFieldDefinition.ts'; import type { SerializedCompositeFieldHolder } from './internal/CompositeFieldHolder.ts'; @@ -120,6 +121,20 @@ export class EntityConfiguration< readonly entityToDBFieldsKeyMapping: ReadonlyMap; readonly dbToEntityFieldsKeyMapping: ReadonlyMap; + readonly inherentFilters: readonly FieldEqualityCondition[]; + + /** + * Cache-key component derived from {@link inherentFilters}; empty string when no filters + * are configured. Two configurations sharing a `tableName` but with different inherent + * filters represent different logical scopes of the same physical table, and must not + * share cache namespaces. Cache adapters include this component in their cache keys so + * that scope-A and scope-B caches stay isolated even when the underlying cache store is + * shared. + * + * @internal + */ + readonly inherentFiltersCacheKeyComponent: string; + readonly databaseAdapterFlavor: DatabaseAdapterFlavor; readonly cacheAdapterFlavor: CacheAdapterFlavor; @@ -130,6 +145,7 @@ export class EntityConfiguration< inboundEdges = [], cacheKeyVersion = 0, compositeFieldDefinitions, + inherentFilters, databaseAdapterFlavor, cacheAdapterFlavor, }: { @@ -165,6 +181,22 @@ export class EntityConfiguration< */ compositeFieldDefinitions?: EntityCompositeFieldDefinition[]; + /** + * Field equality conditions that are inherent to this entity type — i.e., they are AND'd + * into every fetch against the underlying table for this entity. Useful for polymorphic + * tables where multiple entity classes share a row layout (each class registers a + * scope-disambiguating filter so it only ever sees its own rows), and for any other case + * where an entity represents a strict subset of the rows in its table (e.g., a + * `deletedAt IS NULL` invariant, or a tenant scope). + * + * Rows that don't satisfy these filters are invisible to this entity's loaders: a + * `loadByIDAsync` against an excluded row returns null; a `loadManyByFieldEqualingAsync` + * never returns excluded rows; and cascade deletes through this entity's inbound edges + * only see the included scope. Cache keys are not augmented — relying on the fact that + * each entity class has its own configuration and therefore its own cache namespace. + */ + inherentFilters?: readonly FieldEqualityCondition[]; + /** * Backing database and transaction type for this entity. */ @@ -181,6 +213,21 @@ export class EntityConfiguration< this.databaseAdapterFlavor = databaseAdapterFlavor; this.cacheAdapterFlavor = cacheAdapterFlavor; this.inboundEdges = inboundEdges; + this.inherentFilters = inherentFilters ?? []; + this.inherentFiltersCacheKeyComponent = + this.inherentFilters.length === 0 + ? '' + : `f${JSON.stringify( + // Sort by field name so call-order differences in inherentFilters declaration + // don't change the cache key. + [...this.inherentFilters].sort((a, b) => + String(a.fieldName) < String(b.fieldName) + ? -1 + : String(a.fieldName) > String(b.fieldName) + ? 1 + : 0, + ), + )}`; // external schema is a Record to typecheck that all fields have FieldDefinitions, // but internally the most useful representation is a map for lookups diff --git a/packages/entity/src/EntityDatabaseAdapter.ts b/packages/entity/src/EntityDatabaseAdapter.ts index a2efee2e8f..8f7347b68d 100644 --- a/packages/entity/src/EntityDatabaseAdapter.ts +++ b/packages/entity/src/EntityDatabaseAdapter.ts @@ -16,9 +16,52 @@ import { transformFieldsToDatabaseObject, } from './internal/EntityFieldTransformationUtils.ts'; import type { IEntityLoadKey, IEntityLoadValue } from './internal/EntityLoadInterfaces.ts'; +import { EntityLoadMethodType } from './internal/EntityLoadInterfaces.ts'; +import { SingleFieldHolder, SingleFieldValueHolder } from './internal/SingleFieldHolder.ts'; export const RESERVED_ENTITY_COUNT_QUERY_ALIAS = '__entity_count__'; +/** + * Equality operand for selecting entities where a field equals a single value. + */ +export interface SingleValueFieldEqualityCondition< + TFields extends Record, + N extends keyof TFields = keyof TFields, +> { + fieldName: N; + fieldValue: TFields[N]; +} + +/** + * Equality operand for selecting entities where a field equals one of multiple values. + */ +export interface MultiValueFieldEqualityCondition< + TFields extends Record, + N extends keyof TFields = keyof TFields, +> { + fieldName: N; + fieldValues: readonly TFields[N][]; +} + +/** + * A single equality operand for use in a conjunction selection clause. + * See {@link AuthorizationResultBasedEntityLoader.loadManyByFieldEqualityConjunctionAsync} and + * {@link EntityDatabaseAdapter.fetchManyByFieldEqualityConjunctionAsync}. + */ +export type FieldEqualityCondition< + TFields extends Record, + N extends keyof TFields = keyof TFields, +> = SingleValueFieldEqualityCondition | MultiValueFieldEqualityCondition; + +export function isSingleValueFieldEqualityCondition< + TFields extends Record, + N extends keyof TFields = keyof TFields, +>( + condition: FieldEqualityCondition, +): condition is SingleValueFieldEqualityCondition { + return (condition as SingleValueFieldEqualityCondition).fieldValue !== undefined; +} + /** * A database adapter is an interface by which entity objects can be * fetched, inserted, updated, and deleted from a database. This base class @@ -59,26 +102,56 @@ export abstract class EntityDatabaseAdapter< key: TLoadKey, values: readonly TLoadValue[], ): Promise[]>> { - const keyDatabaseColumns = key.getDatabaseColumns(this.entityConfiguration); - const valueDatabaseValues = values.map((value) => key.getDatabaseValues(value)); - - const results = await this.fetchManyWhereInternalAsync( - queryContext.getQueryInterface(), - this.entityConfiguration.tableName, - keyDatabaseColumns, - valueDatabaseValues, - ); - - const objects = results.map((result) => - transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result), - ); - const objectMap = key.vendNewLoadValueMap[]>(); for (const value of values) { objectMap.set(value, []); } - objects.forEach((object) => { + const inherentFilters = this.entityConfiguration.inherentFilters; + + let objects: readonly Readonly[]; + + // When the entity has inherent filters and the load is on a single field, route the + // fetch through fetchManyByFieldEqualityConjunctionAsync so that adapters with native + // conjunction support (e.g. the knex adapter) can push the inherent filters all the + // way down to SQL. Composite-key loads fall through to fetchManyWhereInternalAsync and + // apply inherent filters as an in-memory post-filter, since their `(col1, col2) IN + // tuples` shape doesn't decompose to per-column equality. + if ( + inherentFilters.length > 0 && + key.getLoadMethodType() === EntityLoadMethodType.SINGLE && + values.length > 0 + ) { + const singleFieldKey = key as unknown as SingleFieldHolder; + const fieldName = singleFieldKey.fieldName; + const fieldValues = ( + values as unknown as readonly SingleFieldValueHolder[] + ).map((v) => v.fieldValue); + const operands: FieldEqualityCondition[] = [ + { fieldName, fieldValues } as FieldEqualityCondition, + ]; + // fetchManyByFieldEqualityConjunctionAsync also AND's in inherent filters internally. + objects = await this.fetchManyByFieldEqualityConjunctionAsync(queryContext, operands); + } else { + const keyDatabaseColumns = key.getDatabaseColumns(this.entityConfiguration); + const valueDatabaseValues = values.map((value) => key.getDatabaseValues(value)); + + const results = await this.fetchManyWhereInternalAsync( + queryContext.getQueryInterface(), + this.entityConfiguration.tableName, + keyDatabaseColumns, + valueDatabaseValues, + ); + + const transformed = results.map((result) => + transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result), + ); + + objects = + inherentFilters.length > 0 ? this.applyInherentFiltersInMemory(transformed) : transformed; + } + + for (const object of objects) { const objectMapKeyForObject = key.getLoadValueForObject(object); invariant( objectMapKeyForObject !== null, @@ -90,11 +163,29 @@ export abstract class EntityDatabaseAdapter< `Unexpected object field value during database result transformation: ${objectMapKeyForObject}. This should never happen.`, ); objectList.push(object); - }); + } return objectMap; } + private applyInherentFiltersInMemory( + objects: readonly Readonly[], + ): readonly Readonly[] { + // Callers must guard on inherentFilters.length > 0; this method assumes there is at + // least one filter to apply. An empty `every` would no-op, but the guard at the call + // site keeps the common case (no filters) free of the array.filter overhead. + const inherentFilters = this.entityConfiguration.inherentFilters; + return objects.filter((object) => + inherentFilters.every((operand) => { + const value = object[operand.fieldName]; + if (isSingleValueFieldEqualityCondition(operand)) { + return value === operand.fieldValue; + } + return operand.fieldValues.some((v) => v === value); + }), + ); + } + protected abstract fetchManyWhereInternalAsync( queryInterface: any, tableName: string, @@ -121,6 +212,25 @@ export abstract class EntityDatabaseAdapter< key: TLoadKey, value: TLoadValue, ): Promise | null> { + const inherentFilters = this.entityConfiguration.inherentFilters; + + // Mirror fetchManyWhereAsync: when there are inherent filters and the key is a single + // field, route through the conjunction path so adapters with native conjunction + // support can apply the filters in SQL. + if (inherentFilters.length > 0 && key.getLoadMethodType() === EntityLoadMethodType.SINGLE) { + const singleFieldKey = key as unknown as SingleFieldHolder; + const singleFieldValue = value as unknown as SingleFieldValueHolder; + const operands: FieldEqualityCondition[] = [ + { + fieldName: singleFieldKey.fieldName, + fieldValue: singleFieldValue.fieldValue, + } as FieldEqualityCondition, + ...inherentFilters, + ]; + const matches = await this.fetchManyByFieldEqualityConjunctionAsync(queryContext, operands); + return matches[0] ?? null; + } + const keyDatabaseColumns = key.getDatabaseColumns(this.entityConfiguration); const valueDatabaseValue = key.getDatabaseValues(value); @@ -135,11 +245,18 @@ export abstract class EntityDatabaseAdapter< return null; } - return transformDatabaseObjectToFields( + const object = transformDatabaseObjectToFields( this.entityConfiguration, this.fieldTransformerMap, result, ); + + if (inherentFilters.length > 0) { + const [matched] = this.applyInherentFiltersInMemory([object]); + return matched ?? null; + } + + return object; } protected abstract fetchOneWhereInternalAsync( @@ -149,6 +266,88 @@ export abstract class EntityDatabaseAdapter< tableTuple: readonly any[], ): Promise; + /** + * Fetch objects matching the conjunction of equality operands. Each operand asserts that a + * field equals a single value (SingleValueFieldEqualityCondition) or one of multiple values + * (MultiValueFieldEqualityCondition). All operands are AND'd together. The entity + * configuration's {@link EntityConfiguration.inherentFilters} are also AND'd in. + * + * The default implementation builds the SQL WHERE clause from the single-value operands + * (via {@link fetchManyWhereInternalAsync}) and applies multi-value operands as an + * in-memory filter. Concrete adapters with native conjunction support should override + * this method. + * + * Limitations of the default implementation: + * - Requires at least one single-value operand (after combining caller-supplied operands + * with the entity's inherent filters). Pure multi-value-only conjunctions throw. + * - Single-value operands with a null `fieldValue` will not match anything (NULL semantics). + * Adapters that need to support null-equality operands should override this method. + * + * @param queryContext - query context with which to perform the fetch + * @param fieldEqualityOperands - list of field equality where-clause operands AND'd together + * @returns array of objects matching the conjunction + */ + async fetchManyByFieldEqualityConjunctionAsync( + queryContext: EntityQueryContext, + fieldEqualityOperands: readonly FieldEqualityCondition[], + ): Promise[]> { + const combinedOperands: readonly FieldEqualityCondition[] = [ + ...fieldEqualityOperands, + ...(this.entityConfiguration.inherentFilters as readonly FieldEqualityCondition< + TFields, + N + >[]), + ]; + + const singleValueOperands: SingleValueFieldEqualityCondition[] = []; + const multiValueOperands: MultiValueFieldEqualityCondition[] = []; + for (const operand of combinedOperands) { + if (isSingleValueFieldEqualityCondition(operand)) { + singleValueOperands.push(operand); + } else { + multiValueOperands.push(operand); + } + } + + if (multiValueOperands.some((op) => op.fieldValues.length === 0)) { + return []; + } + + invariant( + singleValueOperands.length > 0, + 'EntityDatabaseAdapter default fetchManyByFieldEqualityConjunctionAsync requires at least ' + + 'one single-value field equality operand. Subclasses may override this method for more ' + + 'general support.', + ); + + const tableColumns = singleValueOperands.map((op) => + getDatabaseFieldForEntityField(this.entityConfiguration, op.fieldName), + ); + const tableTuple = singleValueOperands.map((op) => op.fieldValue); + + const rawRows = await this.fetchManyWhereInternalAsync( + queryContext.getQueryInterface(), + this.entityConfiguration.tableName, + tableColumns, + [tableTuple], + ); + + const transformedRows = rawRows.map((row) => + transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, row), + ); + + if (multiValueOperands.length === 0) { + return transformedRows; + } + + return transformedRows.filter((row) => + multiValueOperands.every((op) => { + const rowValue = row[op.fieldName]; + return op.fieldValues.some((v) => v === rowValue); + }), + ); + } + /** * Insert an object. * diff --git a/packages/entity/src/__tests__/AuthorizationResultBasedEntityLoader-test.ts b/packages/entity/src/__tests__/AuthorizationResultBasedEntityLoader-test.ts index 78214cba07..6261385d42 100644 --- a/packages/entity/src/__tests__/AuthorizationResultBasedEntityLoader-test.ts +++ b/packages/entity/src/__tests__/AuthorizationResultBasedEntityLoader-test.ts @@ -321,6 +321,110 @@ describe(AuthorizationResultBasedEntityLoader, () => { ).toEqual([id3]); }); + it('loads entities by a field equality conjunction', async () => { + const dateToInsert = new Date(); + const viewerContext = instance(mock(ViewerContext)); + const privacyPolicyEvaluationContext = + instance( + mock< + EntityPrivacyPolicyEvaluationContext< + TestFields, + 'customIdField', + ViewerContext, + TestEntity + > + >(), + ); + const metricsAdapter = instance(mock()); + const queryContext = new StubQueryContextProvider().getQueryContext(); + + const id1 = uuidv4(); + const id2 = uuidv4(); + const id3 = uuidv4(); + const databaseAdapter = new StubDatabaseAdapter( + testEntityConfiguration, + StubDatabaseAdapter.convertFieldObjectsToDataStore( + testEntityConfiguration, + new Map([ + [ + testEntityConfiguration.tableName, + [ + { + customIdField: id1, + testIndexedField: 'h1', + intField: 5, + stringField: 'huh', + dateField: dateToInsert, + nullableField: null, + }, + { + customIdField: id2, + testIndexedField: 'h2', + intField: 5, + stringField: 'huh', + dateField: dateToInsert, + nullableField: null, + }, + { + customIdField: id3, + testIndexedField: 'h3', + intField: 3, + stringField: 'huh', + dateField: dateToInsert, + nullableField: null, + }, + ], + ], + ]), + ), + ); + const privacyPolicy = new TestEntityPrivacyPolicy(); + const cacheAdapterProvider = new NoCacheStubCacheAdapterProvider(); + const cacheAdapter = cacheAdapterProvider.getCacheAdapter(testEntityConfiguration); + const entityCache = new ReadThroughEntityCache(testEntityConfiguration, cacheAdapter); + const dataManager = new EntityDataManager( + databaseAdapter, + entityCache, + new StubQueryContextProvider(), + instance(mock()), + TestEntity.name, + ); + const constructionUtils = new EntityConstructionUtils( + viewerContext, + queryContext, + privacyPolicyEvaluationContext, + testEntityConfiguration, + TestEntity, + /* entitySelectedFields */ undefined, + privacyPolicy, + metricsAdapter, + ); + const entityLoader = new AuthorizationResultBasedEntityLoader( + queryContext, + testEntityConfiguration, + TestEntity, + dataManager, + constructionUtils, + ); + + // Single-value AND multi-value operands combined: stringField='huh' AND intField IN [5]. + const conjunctionResults = await enforceResultsAsync( + entityLoader.loadManyByFieldEqualityConjunctionAsync([ + { fieldName: 'stringField', fieldValue: 'huh' }, + { fieldName: 'intField', fieldValues: [5] }, + ]), + ); + expect(conjunctionResults.map((m) => m.getID()).sort()).toEqual([id1, id2].sort()); + + // Validates each operand's value type — passing the wrong-type value (number for a + // string field) throws. + await expect( + entityLoader.loadManyByFieldEqualityConjunctionAsync([ + { fieldName: 'stringField', fieldValue: 5 as unknown as string }, + ]), + ).rejects.toThrow(); + }); + it('authorizes loaded entities', async () => { const privacyPolicy = new TestEntityPrivacyPolicy(); const spiedPrivacyPolicy = spy(privacyPolicy); diff --git a/packages/entity/src/__tests__/EnforcingEntityLoader-test.ts b/packages/entity/src/__tests__/EnforcingEntityLoader-test.ts index 051debb7eb..eabc039697 100644 --- a/packages/entity/src/__tests__/EnforcingEntityLoader-test.ts +++ b/packages/entity/src/__tests__/EnforcingEntityLoader-test.ts @@ -217,6 +217,38 @@ describe(EnforcingEntityLoader, () => { }); }); + describe('loadManyByFieldEqualityConjunctionAsync', () => { + it('throws when result is unsuccessful', async () => { + const nonEnforcingEntityLoaderMock = mock< + AuthorizationResultBasedEntityLoader + >(AuthorizationResultBasedEntityLoader); + const rejection = new Error(); + when( + nonEnforcingEntityLoaderMock.loadManyByFieldEqualityConjunctionAsync(anything()), + ).thenResolve([result(rejection)]); + const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); + await expect( + enforcingEntityLoader.loadManyByFieldEqualityConjunctionAsync(anything()), + ).rejects.toThrow(rejection); + }); + + it('returns value when result is successful', async () => { + const nonEnforcingEntityLoaderMock = mock< + AuthorizationResultBasedEntityLoader + >(AuthorizationResultBasedEntityLoader); + const resolved = {}; + when( + nonEnforcingEntityLoaderMock.loadManyByFieldEqualityConjunctionAsync(anything()), + ).thenResolve([result(resolved)]); + const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); + await expect( + enforcingEntityLoader.loadManyByFieldEqualityConjunctionAsync(anything()), + ).resolves.toEqual([resolved]); + }); + }); + describe('loadByFieldEqualingAsync', () => { it('throws when result is unsuccessful', async () => { const nonEnforcingEntityLoaderMock = mock< diff --git a/packages/entity/src/__tests__/EntityConfiguration-test.ts b/packages/entity/src/__tests__/EntityConfiguration-test.ts index aec7ac7985..1d211949ed 100644 --- a/packages/entity/src/__tests__/EntityConfiguration-test.ts +++ b/packages/entity/src/__tests__/EntityConfiguration-test.ts @@ -156,6 +156,105 @@ describe(EntityConfiguration, () => { expect(entityConfiguration.cacheKeyVersion).toEqual(100); }); }); + + describe('inherentFilters', () => { + type WithFilters = { id: string; scope: string; tenant: string }; + + it('defaults to an empty array and produces an empty cache key component', () => { + const config = new EntityConfiguration({ + idField: 'id', + tableName: 'with_filters', + schema: { + id: new UUIDField({ columnName: 'id', cache: false }), + scope: new StringField({ columnName: 'scope' }), + tenant: new StringField({ columnName: 'tenant' }), + }, + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', + }); + expect(config.inherentFilters).toEqual([]); + expect(config.inherentFiltersCacheKeyComponent).toEqual(''); + }); + + it('produces a deterministic cache key component independent of operand declaration order', () => { + const configAB = new EntityConfiguration({ + idField: 'id', + tableName: 'with_filters', + schema: { + id: new UUIDField({ columnName: 'id', cache: false }), + scope: new StringField({ columnName: 'scope' }), + tenant: new StringField({ columnName: 'tenant' }), + }, + inherentFilters: [ + { fieldName: 'scope', fieldValue: 'A' }, + { fieldName: 'tenant', fieldValue: 'x' }, + ], + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', + }); + const configBA = new EntityConfiguration({ + idField: 'id', + tableName: 'with_filters', + schema: { + id: new UUIDField({ columnName: 'id', cache: false }), + scope: new StringField({ columnName: 'scope' }), + tenant: new StringField({ columnName: 'tenant' }), + }, + // Same filters, reversed declaration order — the cache key component must be + // the same so cache lookups don't depend on how the user listed the operands. + inherentFilters: [ + { fieldName: 'tenant', fieldValue: 'x' }, + { fieldName: 'scope', fieldValue: 'A' }, + ], + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', + }); + expect(configAB.inherentFiltersCacheKeyComponent).toEqual( + configBA.inherentFiltersCacheKeyComponent, + ); + expect(configAB.inherentFiltersCacheKeyComponent).not.toEqual(''); + }); + + it('handles multiple filters on the same field name without throwing', () => { + // Two filters on the same fieldName exercises the equality branch of the sort + // comparator that orders inherentFilters before serialization. + const config = new EntityConfiguration({ + idField: 'id', + tableName: 'with_filters', + schema: { + id: new UUIDField({ columnName: 'id', cache: false }), + scope: new StringField({ columnName: 'scope' }), + tenant: new StringField({ columnName: 'tenant' }), + }, + inherentFilters: [ + { fieldName: 'scope', fieldValue: 'A' }, + { fieldName: 'scope', fieldValues: ['A', 'B'] }, + ], + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', + }); + expect(config.inherentFiltersCacheKeyComponent).not.toEqual(''); + }); + + it('produces different cache key components for different filter values', () => { + const makeConfig = (scopeValue: string): EntityConfiguration => + new EntityConfiguration({ + idField: 'id', + tableName: 'with_filters', + schema: { + id: new UUIDField({ columnName: 'id', cache: false }), + scope: new StringField({ columnName: 'scope' }), + tenant: new StringField({ columnName: 'tenant' }), + }, + inherentFilters: [{ fieldName: 'scope', fieldValue: scopeValue }], + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', + }); + expect(makeConfig('A').inherentFiltersCacheKeyComponent).not.toEqual( + makeConfig('B').inherentFiltersCacheKeyComponent, + ); + }); + }); }); describe('validation', () => { diff --git a/packages/entity/src/__tests__/EntityDatabaseAdapter-test.ts b/packages/entity/src/__tests__/EntityDatabaseAdapter-test.ts index cc50e4b5b0..3452ac2200 100644 --- a/packages/entity/src/__tests__/EntityDatabaseAdapter-test.ts +++ b/packages/entity/src/__tests__/EntityDatabaseAdapter-test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from '@jest/globals'; import { instance, mock } from 'ts-mockito'; +import { EntityConfiguration } from '../EntityConfiguration.ts'; import { EntityDatabaseAdapter } from '../EntityDatabaseAdapter.ts'; +import { IntField, StringField, UUIDField, DateField } from '../EntityFields.ts'; import { EntityQueryContext } from '../EntityQueryContext.ts'; import { EntityDatabaseAdapterEmptyInsertResultError, @@ -32,14 +34,16 @@ class TestEntityDatabaseAdapter extends EntityDatabaseAdapter; }) { - super(testEntityConfiguration); + super(entityConfiguration); this.fetchResults = fetchResults; this.fetchOneResult = fetchOneResult; this.insertResults = insertResults; @@ -228,6 +232,223 @@ describe(EntityDatabaseAdapter, () => { }); }); + describe('fetchManyByFieldEqualityConjunctionAsync', () => { + it('builds a single-value WHERE and transforms returned rows', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const adapter = new TestEntityDatabaseAdapter({ + fetchResults: [{ string_field: 'hello', number_field: 1 }], + }); + const result = await adapter.fetchManyByFieldEqualityConjunctionAsync(queryContext, [ + { fieldName: 'stringField', fieldValue: 'hello' }, + ]); + expect(result).toEqual([{ stringField: 'hello', intField: 1 }]); + }); + + it('applies multi-value operands as an in-memory filter on the fetched rows', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const adapter = new TestEntityDatabaseAdapter({ + fetchResults: [ + { string_field: 'hello', number_field: 1 }, + { string_field: 'hello', number_field: 2 }, + { string_field: 'hello', number_field: 3 }, + ], + }); + const result = await adapter.fetchManyByFieldEqualityConjunctionAsync(queryContext, [ + { fieldName: 'stringField', fieldValue: 'hello' }, + { fieldName: 'intField', fieldValues: [1, 3] }, + ]); + expect(result).toEqual([ + { stringField: 'hello', intField: 1 }, + { stringField: 'hello', intField: 3 }, + ]); + }); + + it('returns empty when any multi-value operand has no values', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const adapter = new TestEntityDatabaseAdapter({ + fetchResults: [{ string_field: 'hello', number_field: 1 }], + }); + const result = await adapter.fetchManyByFieldEqualityConjunctionAsync(queryContext, [ + { fieldName: 'stringField', fieldValue: 'hello' }, + { fieldName: 'intField', fieldValues: [] }, + ]); + expect(result).toEqual([]); + }); + + it('throws when no single-value operand is provided', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const adapter = new TestEntityDatabaseAdapter({}); + await expect( + adapter.fetchManyByFieldEqualityConjunctionAsync(queryContext, [ + { fieldName: 'intField', fieldValues: [1, 2] }, + ]), + ).rejects.toThrow(/requires at least one single-value field equality operand/); + }); + }); + + describe('with inherent filters', () => { + // A configuration with a single-value inherent filter and a multi-value inherent + // filter. The single-value one becomes part of the WHERE clause (which the stub + // adapter does not honor); the multi-value one is applied as an in-memory pass, + // which the stub adapter does honor, letting us verify the filter actually filters. + const testEntityConfigurationWithFilters = new EntityConfiguration( + { + idField: 'customIdField', + tableName: 'test_entity_should_not_write_to_db', + schema: { + customIdField: new UUIDField({ columnName: 'custom_id', cache: true }), + testIndexedField: new StringField({ columnName: 'test_index', cache: true }), + stringField: new StringField({ columnName: 'string_field' }), + intField: new IntField({ columnName: 'number_field' }), + dateField: new DateField({ columnName: 'date_field' }), + nullableField: new StringField({ columnName: 'nullable_field' }), + }, + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', + inherentFilters: [ + { fieldName: 'stringField', fieldValue: 'in-scope' }, + { fieldName: 'testIndexedField', fieldValues: ['a', 'b'] }, + ], + }, + ); + + it("fetchManyByFieldEqualityConjunctionAsync AND's inherent filters into the operand list", async () => { + const queryContext = instance(mock(EntityQueryContext)); + const adapter = new TestEntityDatabaseAdapter({ + entityConfiguration: testEntityConfigurationWithFilters, + // Three rows that "match" the caller's WHERE — the in-memory pass on the + // testIndexedField multi-value inherent filter strips the third. + fetchResults: [ + { string_field: 'in-scope', test_index: 'a', number_field: 1 }, + { string_field: 'in-scope', test_index: 'b', number_field: 2 }, + { string_field: 'in-scope', test_index: 'c', number_field: 3 }, + ], + }); + const result = await adapter.fetchManyByFieldEqualityConjunctionAsync(queryContext, [ + { fieldName: 'intField', fieldValues: [1, 2, 3] }, + ]); + expect(result.map((r) => r.intField).sort()).toEqual([1, 2]); + }); + + it('fetchManyWhereAsync with a single-field key routes through the conjunction path', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const adapter = new TestEntityDatabaseAdapter({ + entityConfiguration: testEntityConfigurationWithFilters, + // The conjunction default impl post-filters on the testIndexedField multi-value + // inherent filter, stripping the row with test_index='z'. + fetchResults: [ + { string_field: 'in-scope', test_index: 'a', number_field: 7 }, + { string_field: 'in-scope', test_index: 'z', number_field: 7 }, + ], + }); + const result = await adapter.fetchManyWhereAsync( + queryContext, + new SingleFieldHolder('intField'), + [new SingleFieldValueHolder(7)], + ); + expect(result.get(new SingleFieldValueHolder(7))).toEqual([ + { stringField: 'in-scope', testIndexedField: 'a', intField: 7 }, + ]); + }); + + it('fetchManyWhereAsync with a composite-field key applies inherent filters in memory', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const adapter = new TestEntityDatabaseAdapter({ + entityConfiguration: testEntityConfigurationWithFilters, + fetchResults: [ + { string_field: 'in-scope', test_index: 'a', number_field: 1 }, + { string_field: 'out-of-scope', test_index: 'a', number_field: 1 }, + { string_field: 'in-scope', test_index: 'z', number_field: 1 }, + ], + }); + const result = await adapter.fetchManyWhereAsync( + queryContext, + new CompositeFieldHolder(['intField', 'stringField']), + [new CompositeFieldValueHolder({ intField: 1, stringField: 'in-scope' })], + ); + // The composite-key path uses applyInherentFiltersInMemory, which honors BOTH the + // single-value stringField filter and the multi-value testIndexedField filter. + expect( + result.get(new CompositeFieldValueHolder({ intField: 1, stringField: 'in-scope' })), + ).toEqual([{ stringField: 'in-scope', testIndexedField: 'a', intField: 1 }]); + }); + + it('fetchOneWhereAsync with a single-field key routes through the conjunction path', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const adapter = new TestEntityDatabaseAdapter({ + entityConfiguration: testEntityConfigurationWithFilters, + fetchResults: [ + { string_field: 'in-scope', test_index: 'a', number_field: 42 }, + { string_field: 'in-scope', test_index: 'z', number_field: 42 }, + ], + }); + const result = await adapter.fetchOneWhereAsync( + queryContext, + new SingleFieldHolder('intField'), + new SingleFieldValueHolder(42), + ); + // The route-through-conjunction path post-filters on testIndexedField, dropping + // the test_index='z' row. + expect(result).toEqual({ stringField: 'in-scope', testIndexedField: 'a', intField: 42 }); + }); + + it('fetchOneWhereAsync returns null when the conjunction yields no match', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const adapter = new TestEntityDatabaseAdapter({ + entityConfiguration: testEntityConfigurationWithFilters, + fetchResults: [{ string_field: 'in-scope', test_index: 'z', number_field: 42 }], + }); + const result = await adapter.fetchOneWhereAsync( + queryContext, + new SingleFieldHolder('intField'), + new SingleFieldValueHolder(42), + ); + expect(result).toBeNull(); + }); + + it('fetchOneWhereAsync with a composite-field key applies inherent filters in memory', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const adapter = new TestEntityDatabaseAdapter({ + entityConfiguration: testEntityConfigurationWithFilters, + fetchOneResult: { string_field: 'in-scope', test_index: 'a', number_field: 1 }, + }); + const result = await adapter.fetchOneWhereAsync( + queryContext, + new CompositeFieldHolder(['intField', 'stringField']), + new CompositeFieldValueHolder({ intField: 1, stringField: 'in-scope' }), + ); + expect(result).toEqual({ stringField: 'in-scope', testIndexedField: 'a', intField: 1 }); + }); + + it('fetchOneWhereAsync with a composite-field key returns null when the single-value inherent filter rejects the row', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const adapter = new TestEntityDatabaseAdapter({ + entityConfiguration: testEntityConfigurationWithFilters, + fetchOneResult: { string_field: 'out-of-scope', test_index: 'a', number_field: 1 }, + }); + const result = await adapter.fetchOneWhereAsync( + queryContext, + new CompositeFieldHolder(['intField', 'stringField']), + new CompositeFieldValueHolder({ intField: 1, stringField: 'out-of-scope' }), + ); + expect(result).toBeNull(); + }); + + it('fetchOneWhereAsync with a composite-field key returns null when the multi-value inherent filter rejects the row', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const adapter = new TestEntityDatabaseAdapter({ + entityConfiguration: testEntityConfigurationWithFilters, + fetchOneResult: { string_field: 'in-scope', test_index: 'z', number_field: 1 }, + }); + const result = await adapter.fetchOneWhereAsync( + queryContext, + new CompositeFieldHolder(['intField', 'stringField']), + new CompositeFieldValueHolder({ intField: 1, stringField: 'in-scope' }), + ); + expect(result).toBeNull(); + }); + }); + describe('insertAsync', () => { it('transforms object', async () => { const queryContext = instance(mock(EntityQueryContext)); diff --git a/packages/entity/src/__tests__/EntityInherentFilters-test.ts b/packages/entity/src/__tests__/EntityInherentFilters-test.ts new file mode 100644 index 0000000000..da40568d98 --- /dev/null +++ b/packages/entity/src/__tests__/EntityInherentFilters-test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it } from '@jest/globals'; + +import { Entity } from '../Entity.ts'; +import type { EntityCompanionDefinition } from '../EntityCompanionProvider.ts'; +import { EntityConfiguration } from '../EntityConfiguration.ts'; +import { EntityEdgeDeletionBehavior } from '../EntityFieldDefinition.ts'; +import { StringField, UUIDField } from '../EntityFields.ts'; +import { EntityPrivacyPolicy } from '../EntityPrivacyPolicy.ts'; +import { ViewerContext } from '../ViewerContext.ts'; +import { AlwaysAllowPrivacyPolicyRule } from '../rules/AlwaysAllowPrivacyPolicyRule.ts'; +import { createUnitTestEntityCompanionProvider } from '../utils/__testfixtures__/createUnitTestEntityCompanionProvider.ts'; + +// Two entity classes share a single `poly_children` table; each enforces a scope-specific +// constructor invariant. Each entity's configuration carries `inherentFilters` that scopes +// every load through that class to its own subset of rows. + +class TestEntityPrivacyPolicy extends EntityPrivacyPolicy { + protected override readonly readRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly createRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly updateRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly deleteRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; +} + +interface ParentFields { + id: string; +} + +interface PolyChildFields { + id: string; + parent_id: string; + scope: 'A' | 'B'; +} + +type EntityConstructorParams< + TFields, + TIDField extends keyof TFields, + TSelectedFields extends keyof TFields = keyof TFields, +> = { + viewerContext: ViewerContext; + id: TFields[TIDField]; + databaseFields: Readonly; + selectedFields: Readonly>; +}; + +class ParentEntity extends Entity { + static defineCompanionDefinition(): EntityCompanionDefinition< + ParentFields, + 'id', + ViewerContext, + ParentEntity, + TestEntityPrivacyPolicy + > { + return { + entityClass: ParentEntity, + entityConfiguration: parentEntityConfiguration, + privacyPolicyClass: TestEntityPrivacyPolicy, + }; + } +} + +class ScopeAChildEntity extends Entity { + constructor(constructorParams: EntityConstructorParams) { + if (constructorParams.databaseFields.scope !== 'A') { + throw new Error( + `ScopeAChildEntity requires scope='A', got '${constructorParams.databaseFields.scope}'`, + ); + } + super(constructorParams); + } + + static defineCompanionDefinition(): EntityCompanionDefinition< + PolyChildFields, + 'id', + ViewerContext, + ScopeAChildEntity, + TestEntityPrivacyPolicy + > { + return { + entityClass: ScopeAChildEntity, + entityConfiguration: scopeAChildEntityConfiguration, + privacyPolicyClass: TestEntityPrivacyPolicy, + }; + } +} + +class ScopeBChildEntity extends Entity { + constructor(constructorParams: EntityConstructorParams) { + if (constructorParams.databaseFields.scope !== 'B') { + throw new Error( + `ScopeBChildEntity requires scope='B', got '${constructorParams.databaseFields.scope}'`, + ); + } + super(constructorParams); + } + + static defineCompanionDefinition(): EntityCompanionDefinition< + PolyChildFields, + 'id', + ViewerContext, + ScopeBChildEntity, + TestEntityPrivacyPolicy + > { + return { + entityClass: ScopeBChildEntity, + entityConfiguration: scopeBChildEntityConfiguration, + privacyPolicyClass: TestEntityPrivacyPolicy, + }; + } +} + +const parentEntityConfiguration = new EntityConfiguration({ + idField: 'id', + tableName: 'poly_parents', + inboundEdges: [ScopeAChildEntity, ScopeBChildEntity], + schema: { + id: new UUIDField({ columnName: 'id', cache: true }), + }, + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', +}); + +const scopeAChildEntityConfiguration = new EntityConfiguration({ + 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: ParentEntity, + edgeDeletionBehavior: EntityEdgeDeletionBehavior.CASCADE_DELETE, + }, + }), + scope: new StringField({ columnName: 'scope' }), + }, + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', +}); + +const scopeBChildEntityConfiguration = new EntityConfiguration({ + idField: 'id', + tableName: 'poly_children', + inherentFilters: [{ fieldName: 'scope', fieldValue: 'B' }], + schema: { + id: new UUIDField({ columnName: 'id', cache: true }), + parent_id: new UUIDField({ + columnName: 'parent_id', + association: { + associatedEntityClass: ParentEntity, + edgeDeletionBehavior: EntityEdgeDeletionBehavior.CASCADE_DELETE, + }, + }), + scope: new StringField({ columnName: 'scope' }), + }, + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', +}); + +describe('EntityConfiguration.inherentFilters', () => { + it('loads by ID only when the row matches the inherent filter', async () => { + const viewerContext = new ViewerContext(createUnitTestEntityCompanionProvider()); + const parent = await ParentEntity.creator(viewerContext).createAsync(); + const scopeARow = await ScopeAChildEntity.creator(viewerContext) + .setField('parent_id', parent.getID()) + .setField('scope', 'A') + .createAsync(); + const scopeBRow = await ScopeBChildEntity.creator(viewerContext) + .setField('parent_id', parent.getID()) + .setField('scope', 'B') + .createAsync(); + + // Each row loads through its own class. + await expect( + ScopeAChildEntity.loader(viewerContext).loadByIDNullableAsync(scopeARow.getID()), + ).resolves.not.toBeNull(); + await expect( + ScopeBChildEntity.loader(viewerContext).loadByIDNullableAsync(scopeBRow.getID()), + ).resolves.not.toBeNull(); + + // Loading a wrong-scope row through the other class returns null (not a constructor + // invariant throw) — the inherent filter excludes it at the SQL level. + await expect( + ScopeAChildEntity.loader(viewerContext).loadByIDNullableAsync(scopeBRow.getID()), + ).resolves.toBeNull(); + await expect( + ScopeBChildEntity.loader(viewerContext).loadByIDNullableAsync(scopeARow.getID()), + ).resolves.toBeNull(); + }); + + it('loadManyByFieldEqualingAsync only returns rows that match the inherent filter', async () => { + const viewerContext = new ViewerContext(createUnitTestEntityCompanionProvider()); + const parent = await ParentEntity.creator(viewerContext).createAsync(); + const scopeARow1 = await ScopeAChildEntity.creator(viewerContext) + .setField('parent_id', parent.getID()) + .setField('scope', 'A') + .createAsync(); + const scopeARow2 = await ScopeAChildEntity.creator(viewerContext) + .setField('parent_id', parent.getID()) + .setField('scope', 'A') + .createAsync(); + await ScopeBChildEntity.creator(viewerContext) + .setField('parent_id', parent.getID()) + .setField('scope', 'B') + .createAsync(); + + const scopeAResults = await ScopeAChildEntity.loader( + viewerContext, + ).loadManyByFieldEqualingAsync('parent_id', parent.getID()); + expect(scopeAResults.map((entity) => entity.getID()).sort()).toEqual( + [scopeARow1.getID(), scopeARow2.getID()].sort(), + ); + + const scopeBResults = await ScopeBChildEntity.loader( + viewerContext, + ).loadManyByFieldEqualingAsync('parent_id', parent.getID()); + expect(scopeBResults).toHaveLength(1); + }); + + it("cascade-delete through inboundEdges only deletes rows that match each class's inherent filter", async () => { + const viewerContext = new ViewerContext(createUnitTestEntityCompanionProvider()); + const parent = await ParentEntity.creator(viewerContext).createAsync(); + const scopeARow = await ScopeAChildEntity.creator(viewerContext) + .setField('parent_id', parent.getID()) + .setField('scope', 'A') + .createAsync(); + const scopeBRow = await ScopeBChildEntity.creator(viewerContext) + .setField('parent_id', parent.getID()) + .setField('scope', 'B') + .createAsync(); + + // Cascade-deleting the parent must not trip either child class's scope invariant — + // each class's cascade load is scoped by its own inherentFilters so it only ever sees + // rows belonging to its scope. + await ParentEntity.deleter(parent).deleteAsync(); + + await expect( + ScopeAChildEntity.loader(viewerContext).loadByIDNullableAsync(scopeARow.getID()), + ).resolves.toBeNull(); + await expect( + ScopeBChildEntity.loader(viewerContext).loadByIDNullableAsync(scopeBRow.getID()), + ).resolves.toBeNull(); + }); + + it('cascade-delete is a no-op for an inbound edge whose inherent filter matches no rows', async () => { + const viewerContext = new ViewerContext(createUnitTestEntityCompanionProvider()); + const parent = await ParentEntity.creator(viewerContext).createAsync(); + // Only a scope-A row exists; the parent's ScopeB cascade load returns zero rows. + const scopeARow = await ScopeAChildEntity.creator(viewerContext) + .setField('parent_id', parent.getID()) + .setField('scope', 'A') + .createAsync(); + + await ParentEntity.deleter(parent).deleteAsync(); + + await expect( + ScopeAChildEntity.loader(viewerContext).loadByIDNullableAsync(scopeARow.getID()), + ).resolves.toBeNull(); + }); +}); diff --git a/packages/entity/src/internal/CompositeFieldHolder.ts b/packages/entity/src/internal/CompositeFieldHolder.ts index 1779c36925..b83c511a0a 100644 --- a/packages/entity/src/internal/CompositeFieldHolder.ts +++ b/packages/entity/src/internal/CompositeFieldHolder.ts @@ -98,7 +98,11 @@ export class CompositeFieldHolder< const compositeFieldValues = this.compositeField.map( (fieldName) => value.compositeFieldValue[fieldName], ); - return [...columnNames, ...compositeFieldValues.map((value) => String(value))]; + const parts = [...columnNames, ...compositeFieldValues.map((value) => String(value))]; + if (entityConfiguration.inherentFiltersCacheKeyComponent !== '') { + parts.push(entityConfiguration.inherentFiltersCacheKeyComponent); + } + return parts; } getLoadMethodType(): EntityLoadMethodType { diff --git a/packages/entity/src/internal/EntityDataManager.ts b/packages/entity/src/internal/EntityDataManager.ts index 942b47d011..de4c5fba0b 100644 --- a/packages/entity/src/internal/EntityDataManager.ts +++ b/packages/entity/src/internal/EntityDataManager.ts @@ -1,12 +1,13 @@ import DataLoader from 'dataloader'; import invariant from 'invariant'; -import type { EntityDatabaseAdapter } from '../EntityDatabaseAdapter.ts'; +import type { EntityDatabaseAdapter, FieldEqualityCondition } from '../EntityDatabaseAdapter.ts'; import type { EntityQueryContext, EntityTransactionalQueryContext } from '../EntityQueryContext.ts'; import { TransactionalDataLoaderMode } from '../EntityQueryContext.ts'; import type { EntityQueryContextProvider } from '../EntityQueryContextProvider.ts'; import { partitionErrors } from '../entityUtils.ts'; import { + timeAndLogLoadEventAsync, timeAndLogLoadMapEventAsync, timeAndLogLoadOneEventAsync, } from '../metrics/EntityMetricsUtils.ts'; @@ -271,6 +272,31 @@ export class EntityDataManager< )(this.databaseAdapter.fetchOneWhereAsync(queryContext, key, value)); } + /** + * Load objects matching the conjunction of field equality operands. Bypasses the + * dataloader and cache; goes directly to the database adapter. + * + * @param queryContext - query context in which to perform the load + * @param fieldEqualityOperands - field equality where-clause operands AND'd together + * @returns array of objects matching the conjunction + */ + public async loadManyByFieldEqualityConjunctionAsync( + queryContext: EntityQueryContext, + fieldEqualityOperands: readonly FieldEqualityCondition[], + ): Promise[]> { + return await timeAndLogLoadEventAsync( + this.metricsAdapter, + EntityMetricsLoadType.LOAD_MANY_EQUALITY_CONJUNCTION, + this.entityClassName, + queryContext, + )( + this.databaseAdapter.fetchManyByFieldEqualityConjunctionAsync( + queryContext, + fieldEqualityOperands, + ), + ); + } + private async invalidateOneAsync< TLoadKey extends IEntityLoadKey, TSerializedLoadValue, diff --git a/packages/entity/src/internal/SingleFieldHolder.ts b/packages/entity/src/internal/SingleFieldHolder.ts index 144e99a564..ce5de2ff25 100644 --- a/packages/entity/src/internal/SingleFieldHolder.ts +++ b/packages/entity/src/internal/SingleFieldHolder.ts @@ -52,7 +52,11 @@ export class SingleFieldHolder< ): readonly string[] { const columnName = entityConfiguration.entityToDBFieldsKeyMapping.get(this.fieldName); invariant(columnName, `database field mapping missing for ${String(this.fieldName)}`); - return [columnName, String(value.fieldValue)]; + const parts = [columnName, String(value.fieldValue)]; + if (entityConfiguration.inherentFiltersCacheKeyComponent !== '') { + parts.push(entityConfiguration.inherentFiltersCacheKeyComponent); + } + return parts; } getLoadMethodType(): EntityLoadMethodType { diff --git a/packages/entity/src/internal/__tests__/CompositeFieldHolder-test.ts b/packages/entity/src/internal/__tests__/CompositeFieldHolder-test.ts index 77a8006648..6fb9719009 100644 --- a/packages/entity/src/internal/__tests__/CompositeFieldHolder-test.ts +++ b/packages/entity/src/internal/__tests__/CompositeFieldHolder-test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from '@jest/globals'; +import { EntityConfiguration } from '../../EntityConfiguration.ts'; +import { StringField, UUIDField } from '../../EntityFields.ts'; import { CompositeFieldHolder, CompositeFieldValueHolder } from '../CompositeFieldHolder.ts'; type TestFields = { @@ -15,6 +17,51 @@ describe(CompositeFieldHolder, () => { expect(compositeFieldHolder.serialize()).toEqual(compositeFieldHolder2.serialize()); }); + + describe('createCacheKeyPartsForLoadValue', () => { + it('omits the inherent-filters component when the entity has none', () => { + const config = new EntityConfiguration({ + idField: 'id', + tableName: 'cache_key_parts_test', + schema: { + id: new UUIDField({ columnName: 'id', cache: true }), + field1: new StringField({ columnName: 'field1' }), + field2: new StringField({ columnName: 'field2' }), + }, + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', + }); + const holder = new CompositeFieldHolder(['field1', 'field2']); + const parts = holder.createCacheKeyPartsForLoadValue( + config, + new CompositeFieldValueHolder({ id: 'unused', field1: 'a', field2: 'b' }), + ); + expect(parts).toEqual(['field1', 'field2', 'a', 'b']); + }); + + it('appends the inherent-filters component when the entity declares filters', () => { + const config = new EntityConfiguration({ + idField: 'id', + tableName: 'cache_key_parts_test', + schema: { + id: new UUIDField({ columnName: 'id', cache: true }), + field1: new StringField({ columnName: 'field1' }), + field2: new StringField({ columnName: 'field2' }), + }, + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', + inherentFilters: [{ fieldName: 'field1', fieldValue: 'in-scope' }], + }); + const holder = new CompositeFieldHolder(['field1', 'field2']); + const parts = holder.createCacheKeyPartsForLoadValue( + config, + new CompositeFieldValueHolder({ id: 'unused', field1: 'in-scope', field2: 'b' }), + ); + expect(parts).toHaveLength(5); + expect(parts.slice(0, 4)).toEqual(['field1', 'field2', 'in-scope', 'b']); + expect(parts[4]).toEqual(config.inherentFiltersCacheKeyComponent); + }); + }); }); describe(CompositeFieldValueHolder, () => { diff --git a/packages/entity/src/metrics/IEntityMetricsAdapter.ts b/packages/entity/src/metrics/IEntityMetricsAdapter.ts index 4a45370caa..dc7d843c00 100644 --- a/packages/entity/src/metrics/IEntityMetricsAdapter.ts +++ b/packages/entity/src/metrics/IEntityMetricsAdapter.ts @@ -13,7 +13,8 @@ export enum EntityMetricsLoadType { */ LOAD_MANY, /** - * Knex loader load using loadManyByFieldEqualityConjunctionAsync. + * Loader load using loadManyByFieldEqualityConjunctionAsync (base entity loader + * or knex loader). */ LOAD_MANY_EQUALITY_CONJUNCTION, /**