From 648de2b6a2bd5cb214fef56b95fd729905dab839 Mon Sep 17 00:00:00 2001 From: Will Schurman Date: Sat, 31 Jan 2026 14:19:19 -0700 Subject: [PATCH] feat!: move knex loaders into entity-database-adapter-knex package --- .ctirc | 18 +- .../GenericLocalMemoryCacher-full-test.ts | 25 +- ...tchedRedisCacheAdapter-integration-test.ts | 17 +- ...enericRedisCacher-full-integration-test.ts | 17 +- .../entity-database-adapter-knex/package.json | 1 + ...uthorizationResultBasedKnexEntityLoader.ts | 24 +- .../src/BasePostgresEntityDatabaseAdapter.ts | 249 ++++++++++++++ .../src/EnforcingKnexEntityLoader.ts | 7 +- .../src/KnexEntityLoader.ts | 13 +- .../src/KnexEntityLoaderFactory.ts | 25 +- .../src/PostgresEntityDatabaseAdapter.ts | 13 +- .../PostgresEntityDatabaseAdapterProvider.ts | 15 + .../ViewerScopedKnexEntityLoaderFactory.ts | 12 +- .../PostgresEntityIntegration-test.ts | 2 +- ...izationResultBasedKnexEntityLoader-test.ts | 206 ++++-------- .../BasePostgresEntityDatabaseAdapter-test.ts | 128 ++++++++ .../EnforcingKnexEntityLoader-test.ts | 0 .../src/__tests__/ReadonlyEntity-test.ts | 27 ++ .../fixtures/StubPostgresDatabaseAdapter.ts | 276 ++++++++++++++++ .../StubPostgresDatabaseAdapterProvider.ts | 32 ++ .../src/__tests__/fixtures/TestEntity.ts | 130 ++++++++ ...UnitTestPostgresEntityCompanionProvider.ts | 42 +++ .../extensions/EntityCompanionExtensions.ts | 81 +++++ .../EntityTableDataCoordinatorExtensions.ts | 43 +++ .../extensions/ReadonlyEntityExtensions.ts | 179 ++++++++++ .../ViewerScopedEntityCompanionExtensions.ts | 72 ++++ .../entity-database-adapter-knex/src/index.ts | 12 + .../src/internal/EntityKnexDataManager.ts | 16 +- .../__tests__/EntityKnexDataManager-test.ts | 200 ++++++------ .../src/utils/EntityPrivacyUtils.ts | 31 +- .../__tests__/EntityPrivacyUtils-test.ts | 60 ++-- ...eletionPermissionInferenceBehavior-test.ts | 30 +- .../src/adapters/InMemoryDatabaseAdapter.ts | 89 +---- .../LocalMemorySecondaryEntityCache-test.ts | 8 +- ...isSecondaryEntityCache-integration-test.ts | 8 +- .../src/StubDatabaseAdapter.ts | 101 ------ .../src/StubDatabaseAdapterProvider.ts | 8 + .../src/__tests__/StubDatabaseAdapter-test.ts | 309 ------------------ .../AuthorizationResultBasedEntityLoader.ts | 23 +- .../AuthorizationResultBasedEntityMutator.ts | 43 ++- packages/entity/src/EntityCompanion.ts | 32 +- .../entity/src/EntityCompanionProvider.ts | 17 +- ...derUtils.ts => EntityConstructionUtils.ts} | 82 +---- packages/entity/src/EntityDatabaseAdapter.ts | 241 +------------- .../entity/src/EntityInvalidationUtils.ts | 115 +++++++ packages/entity/src/EntityLoader.ts | 24 +- packages/entity/src/EntityLoaderFactory.ts | 15 +- .../entity/src/EntitySecondaryCacheLoader.ts | 16 +- .../src/IEntityDatabaseAdapterProvider.ts | 10 + packages/entity/src/ReadonlyEntity.ts | 72 +--- .../entity/src/ViewerScopedEntityCompanion.ts | 20 +- ...esultBasedEntityLoader-constructor-test.ts | 15 +- ...thorizationResultBasedEntityLoader-test.ts | 105 ++++-- .../src/__tests__/EntityCompanion-test.ts | 18 + .../__tests__/EntityDatabaseAdapter-test.ts | 54 +-- .../entity/src/__tests__/EntityLoader-test.ts | 21 +- .../src/__tests__/EntityMutator-test.ts | 28 +- .../EntitySecondaryCacheLoader-test.ts | 17 +- .../GenericSecondaryEntityCache-test.ts | 8 +- .../src/__tests__/ReadonlyEntity-test.ts | 29 +- .../ViewerScopedEntityCompanion-test.ts | 1 + .../TwoEntitySameTableDisjointRows-test.ts | 17 - packages/entity/src/index.ts | 10 +- .../internal/EntityTableDataCoordinator.ts | 11 +- .../__tests__/EntityMetricsUtils-test.ts | 120 +++++++ .../__testfixtures__/StubDatabaseAdapter.ts | 105 +----- .../StubDatabaseAdapterProvider.ts | 8 + yarn.lock | 1 + 68 files changed, 2171 insertions(+), 1633 deletions(-) rename packages/{entity => entity-database-adapter-knex}/src/AuthorizationResultBasedKnexEntityLoader.ts (84%) create mode 100644 packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts rename packages/{entity => entity-database-adapter-knex}/src/EnforcingKnexEntityLoader.ts (96%) rename packages/{entity => entity-database-adapter-knex}/src/KnexEntityLoader.ts (89%) rename packages/{entity => entity-database-adapter-knex}/src/KnexEntityLoaderFactory.ts (77%) rename packages/{entity => entity-database-adapter-knex}/src/ViewerScopedKnexEntityLoaderFactory.ts (85%) rename packages/{entity => entity-database-adapter-knex}/src/__tests__/AuthorizationResultBasedKnexEntityLoader-test.ts (55%) create mode 100644 packages/entity-database-adapter-knex/src/__tests__/BasePostgresEntityDatabaseAdapter-test.ts rename packages/{entity => entity-database-adapter-knex}/src/__tests__/EnforcingKnexEntityLoader-test.ts (100%) create mode 100644 packages/entity-database-adapter-knex/src/__tests__/ReadonlyEntity-test.ts create mode 100644 packages/entity-database-adapter-knex/src/__tests__/fixtures/StubPostgresDatabaseAdapter.ts create mode 100644 packages/entity-database-adapter-knex/src/__tests__/fixtures/StubPostgresDatabaseAdapterProvider.ts create mode 100644 packages/entity-database-adapter-knex/src/__tests__/fixtures/TestEntity.ts create mode 100644 packages/entity-database-adapter-knex/src/__tests__/fixtures/createUnitTestPostgresEntityCompanionProvider.ts create mode 100644 packages/entity-database-adapter-knex/src/extensions/EntityCompanionExtensions.ts create mode 100644 packages/entity-database-adapter-knex/src/extensions/EntityTableDataCoordinatorExtensions.ts create mode 100644 packages/entity-database-adapter-knex/src/extensions/ReadonlyEntityExtensions.ts create mode 100644 packages/entity-database-adapter-knex/src/extensions/ViewerScopedEntityCompanionExtensions.ts rename packages/{entity => entity-database-adapter-knex}/src/internal/EntityKnexDataManager.ts (87%) rename packages/{entity => entity-database-adapter-knex}/src/internal/__tests__/EntityKnexDataManager-test.ts (63%) rename packages/{entity => entity-database-adapter-knex}/src/utils/EntityPrivacyUtils.ts (95%) rename packages/{entity => entity-database-adapter-knex}/src/utils/__tests__/EntityPrivacyUtils-test.ts (94%) rename packages/{entity => entity-database-adapter-knex}/src/utils/__tests__/canViewerDeleteAsync-edgeDeletionPermissionInferenceBehavior-test.ts (90%) rename packages/entity/src/{EntityLoaderUtils.ts => EntityConstructionUtils.ts} (61%) create mode 100644 packages/entity/src/EntityInvalidationUtils.ts create mode 100644 packages/entity/src/metrics/__tests__/EntityMetricsUtils-test.ts diff --git a/.ctirc b/.ctirc index ddb5943a9..b0acb45f9 100644 --- a/.ctirc +++ b/.ctirc @@ -7,44 +7,42 @@ { "project": "packages/entity/tsconfig.json", "output": "packages/entity/src", - "exclude": [ - "**/__testfixtures__/**", - "**/__tests__/**" - ] + "exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ] }, { "project": "packages/entity-cache-adapter-local-memory/tsconfig.json", "output": "packages/entity-cache-adapter-local-memory/src", - "exclude": [ "**/__testfixtures__/**" ] + "exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ] }, { "project": "packages/entity-cache-adapter-redis/tsconfig.json", "output": "packages/entity-cache-adapter-redis/src", - "exclude": [ "**/__testfixtures__/**" ] + "exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ] }, { "project": "packages/entity-database-adapter-knex/tsconfig.json", "output": "packages/entity-database-adapter-knex/src", - "exclude": [ "**/__testfixtures__/**" ] + "exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ] }, { "project": "packages/entity-ip-address-field/tsconfig.json", "output": "packages/entity-ip-address-field/src", + "exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ] }, { "project": "packages/entity-secondary-cache-local-memory/tsconfig.json", "output": "packages/entity-secondary-cache-local-memory/src", - "exclude": [ "**/__tests__/**" ] + "exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ] }, { "project": "packages/entity-secondary-cache-redis/tsconfig.json", "output": "packages/entity-secondary-cache-redis/src", - "exclude": [ "**/__testfixtures__/**" ] + "exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ] }, { "project": "packages/entity-testing-utils/tsconfig.json", "output": "packages/entity-testing-utils/src", - "exclude": [ "**/__testfixtures__/**" ] + "exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ] } ] } diff --git a/packages/entity-cache-adapter-local-memory/src/__tests__/GenericLocalMemoryCacher-full-test.ts b/packages/entity-cache-adapter-local-memory/src/__tests__/GenericLocalMemoryCacher-full-test.ts index 9bb2edf0d..bf4abe8c2 100644 --- a/packages/entity-cache-adapter-local-memory/src/__tests__/GenericLocalMemoryCacher-full-test.ts +++ b/packages/entity-cache-adapter-local-memory/src/__tests__/GenericLocalMemoryCacher-full-test.ts @@ -1,5 +1,6 @@ import { CacheStatus, + GenericEntityCacheAdapter, IEntityGenericCacher, SingleFieldHolder, SingleFieldValueHolder, @@ -23,12 +24,11 @@ describe(GenericLocalMemoryCacher, () => { it('has correct caching behavior', async () => { const entityCompanionProvider = createLocalMemoryTestEntityCompanionProvider(); const viewerContext = new ViewerContext(entityCompanionProvider); - const genericCacher = viewerContext.entityCompanionProvider.getCompanionForEntity( - LocalMemoryTestEntity, - )['tableDataCoordinator']['cacheAdapter']['genericCacher'] as IEntityGenericCacher< - LocalMemoryTestEntityFields, - 'id' - >; + const genericCacher = ( + viewerContext.entityCompanionProvider.getCompanionForEntity(LocalMemoryTestEntity)[ + 'tableDataCoordinator' + ]['cacheAdapter'] as GenericEntityCacheAdapter + )['genericCacher']; const date = new Date(); const entity1Created = await LocalMemoryTestEntity.creator(viewerContext) @@ -105,7 +105,7 @@ describe(GenericLocalMemoryCacher, () => { expect(entityNonExistentResult2.ok).toBe(false); // invalidate from cache to ensure it invalidates correctly - await LocalMemoryTestEntity.loaderUtils(viewerContext).invalidateFieldsAsync( + await LocalMemoryTestEntity.invalidationUtils(viewerContext).invalidateFieldsAsync( entity1.getAllFields(), ); const keys = genericCacher.makeCacheKeysForInvalidation( @@ -121,12 +121,11 @@ describe(GenericLocalMemoryCacher, () => { it('respects the parameters of a noop cache', async () => { const entityCompanionProvider = createNoOpLocalMemoryIntegrationTestEntityCompanionProvider(); const viewerContext = new ViewerContext(entityCompanionProvider); - const genericCacher = viewerContext.entityCompanionProvider.getCompanionForEntity( - LocalMemoryTestEntity, - )['tableDataCoordinator']['cacheAdapter']['genericCacher'] as IEntityGenericCacher< - LocalMemoryTestEntityFields, - 'id' - >; + const genericCacher = ( + viewerContext.entityCompanionProvider.getCompanionForEntity(LocalMemoryTestEntity)[ + 'tableDataCoordinator' + ]['cacheAdapter'] as GenericEntityCacheAdapter + )['genericCacher']; const cacheKeyMaker = genericCacher['makeCacheKeyForStorage'].bind(genericCacher); const date = new Date(); diff --git a/packages/entity-cache-adapter-redis/src/__integration-tests__/BatchedRedisCacheAdapter-integration-test.ts b/packages/entity-cache-adapter-redis/src/__integration-tests__/BatchedRedisCacheAdapter-integration-test.ts index 62b062012..a076ed50d 100644 --- a/packages/entity-cache-adapter-redis/src/__integration-tests__/BatchedRedisCacheAdapter-integration-test.ts +++ b/packages/entity-cache-adapter-redis/src/__integration-tests__/BatchedRedisCacheAdapter-integration-test.ts @@ -1,6 +1,6 @@ import { Batcher } from '@expo/batcher'; import { - IEntityGenericCacher, + GenericEntityCacheAdapter, SingleFieldHolder, SingleFieldValueHolder, ViewerContext, @@ -109,12 +109,11 @@ describe(GenericRedisCacher, () => { const mgetSpy = jest.spyOn(redis, 'mget'); - const genericCacher = viewerContext.entityCompanionProvider.getCompanionForEntity( - RedisTestEntity, - )['tableDataCoordinator']['cacheAdapter']['genericCacher'] as IEntityGenericCacher< - RedisTestEntityFields, - 'id' - >; + const genericCacher = ( + viewerContext.entityCompanionProvider.getCompanionForEntity(RedisTestEntity)[ + 'tableDataCoordinator' + ]['cacheAdapter'] as GenericEntityCacheAdapter + )['genericCacher']; const entity1Created = await RedisTestEntity.creator(viewerContext) .setField('name', 'blah') @@ -187,7 +186,9 @@ describe(GenericRedisCacher, () => { expect(entityNonExistentResult2.ok).toBe(false); // invalidate from cache to ensure it invalidates correctly in both caches - await RedisTestEntity.loaderUtils(viewerContext).invalidateFieldsAsync(entity1.getAllFields()); + await RedisTestEntity.invalidationUtils(viewerContext).invalidateFieldsAsync( + entity1.getAllFields(), + ); await expect(redis.get(cacheKeyEntity1)).resolves.toBeNull(); await expect(redis.get(cacheKeyEntity1NameField)).resolves.toBeNull(); }); diff --git a/packages/entity-cache-adapter-redis/src/__integration-tests__/GenericRedisCacher-full-integration-test.ts b/packages/entity-cache-adapter-redis/src/__integration-tests__/GenericRedisCacher-full-integration-test.ts index 335f3029b..1f3794dc9 100644 --- a/packages/entity-cache-adapter-redis/src/__integration-tests__/GenericRedisCacher-full-integration-test.ts +++ b/packages/entity-cache-adapter-redis/src/__integration-tests__/GenericRedisCacher-full-integration-test.ts @@ -1,7 +1,7 @@ import { CompositeFieldHolder, CompositeFieldValueHolder, - IEntityGenericCacher, + GenericEntityCacheAdapter, SingleFieldHolder, SingleFieldValueHolder, ViewerContext, @@ -55,12 +55,11 @@ describe(GenericRedisCacher, () => { const viewerContext = new TestViewerContext( createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext), ); - const genericCacher = viewerContext.entityCompanionProvider.getCompanionForEntity( - RedisTestEntity, - )['tableDataCoordinator']['cacheAdapter']['genericCacher'] as IEntityGenericCacher< - RedisTestEntityFields, - 'id' - >; + const genericCacher = ( + viewerContext.entityCompanionProvider.getCompanionForEntity(RedisTestEntity)[ + 'tableDataCoordinator' + ]['cacheAdapter'] as GenericEntityCacheAdapter + )['genericCacher']; const entity1Created = await RedisTestEntity.creator(viewerContext) .setField('name', 'blah') @@ -144,7 +143,9 @@ describe(GenericRedisCacher, () => { expect(entityNonExistentCompositeResult2).toBe(null); // invalidate from cache to ensure it invalidates correctly - await RedisTestEntity.loaderUtils(viewerContext).invalidateFieldsAsync(entity1.getAllFields()); + await RedisTestEntity.invalidationUtils(viewerContext).invalidateFieldsAsync( + entity1.getAllFields(), + ); const cachedValueNullKeys = genericCacher.makeCacheKeysForInvalidation( new SingleFieldHolder('id'), new SingleFieldValueHolder(entity1.getID()), diff --git a/packages/entity-database-adapter-knex/package.json b/packages/entity-database-adapter-knex/package.json index bbb1da48a..88e4765c8 100644 --- a/packages/entity-database-adapter-knex/package.json +++ b/packages/entity-database-adapter-knex/package.json @@ -35,6 +35,7 @@ "@expo/entity-testing-utils": "workspace:^", "@jest/globals": "30.2.0", "pg": "8.18.0", + "ts-mockito": "2.6.1", "typescript": "5.9.3" } } diff --git a/packages/entity/src/AuthorizationResultBasedKnexEntityLoader.ts b/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts similarity index 84% rename from packages/entity/src/AuthorizationResultBasedKnexEntityLoader.ts rename to packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts index b310d9d99..2f155fbea 100644 --- a/packages/entity/src/AuthorizationResultBasedKnexEntityLoader.ts +++ b/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts @@ -1,3 +1,11 @@ +import { + EntityConstructionUtils, + EntityPrivacyPolicy, + EntityQueryContext, + ReadonlyEntity, + ViewerContext, + IEntityMetricsAdapter, +} from '@expo/entity'; import { Result } from '@expo/results'; import { @@ -5,14 +13,8 @@ import { isSingleValueFieldEqualityCondition, QuerySelectionModifiers, QuerySelectionModifiersWithOrderByRaw, -} from './EntityDatabaseAdapter'; -import { EntityLoaderUtils } from './EntityLoaderUtils'; -import { EntityPrivacyPolicy } from './EntityPrivacyPolicy'; -import { EntityQueryContext } from './EntityQueryContext'; -import { ReadonlyEntity } from './ReadonlyEntity'; -import { ViewerContext } from './ViewerContext'; +} from './BasePostgresEntityDatabaseAdapter'; import { EntityKnexDataManager } from './internal/EntityKnexDataManager'; -import { IEntityMetricsAdapter } from './metrics/IEntityMetricsAdapter'; /** * Authorization-result-based knex entity loader for non-data-loader-based load methods. @@ -38,7 +40,7 @@ export class AuthorizationResultBasedKnexEntityLoader< private readonly queryContext: EntityQueryContext, private readonly knexDataManager: EntityKnexDataManager, protected readonly metricsAdapter: IEntityMetricsAdapter, - public readonly utils: EntityLoaderUtils< + public readonly constructionUtils: EntityConstructionUtils< TFields, TIDField, TViewerContext, @@ -77,7 +79,7 @@ export class AuthorizationResultBasedKnexEntityLoader< const fieldValues = isSingleValueFieldEqualityCondition(fieldEqualityOperand) ? [fieldEqualityOperand.fieldValue] : fieldEqualityOperand.fieldValues; - this.utils.validateFieldAndValues(fieldEqualityOperand.fieldName, fieldValues); + this.constructionUtils.validateFieldAndValues(fieldEqualityOperand.fieldName, fieldValues); } const fieldObjects = await this.knexDataManager.loadManyByFieldEqualityConjunctionAsync( @@ -85,7 +87,7 @@ export class AuthorizationResultBasedKnexEntityLoader< fieldEqualityOperands, querySelectionModifiers, ); - return await this.utils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects); + return await this.constructionUtils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects); } /** @@ -104,6 +106,6 @@ export class AuthorizationResultBasedKnexEntityLoader< bindings, querySelectionModifiers, ); - return await this.utils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects); + return await this.constructionUtils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects); } } diff --git a/packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts b/packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts new file mode 100644 index 000000000..8cccea781 --- /dev/null +++ b/packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts @@ -0,0 +1,249 @@ +import { + EntityDatabaseAdapter, + EntityQueryContext, + getDatabaseFieldForEntityField, + transformDatabaseObjectToFields, +} from '@expo/entity'; +import { Knex } from 'knex'; + +/** + * 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; +} + +export interface TableFieldMultiValueEqualityCondition { + tableField: string; + tableValues: readonly any[]; +} + +/** + * Ordering options for `orderBy` clauses. + */ +export enum OrderByOrdering { + /** + * Ascending order (lowest to highest). + * Ascending order puts smaller values first, where "smaller" is defined in terms of the %3C operator. + */ + ASCENDING = 'asc', + + /** + * Descending order (highest to lowest). + * Descending order puts larger values first, where "larger" is defined in terms of the %3E operator. + */ + DESCENDING = 'desc', +} + +/** + * SQL modifiers that only affect the selection but not the projection. + */ +export interface QuerySelectionModifiers> { + /** + * Order the entities by specified columns and orders. + */ + orderBy?: { + /** + * The field name to order by. + */ + fieldName: keyof TFields; + + /** + * The OrderByOrdering to order by. + */ + order: OrderByOrdering; + }[]; + + /** + * Skip the specified number of entities queried before returning. + */ + offset?: number; + + /** + * Limit the number of entities returned. + */ + limit?: number; +} + +export interface QuerySelectionModifiersWithOrderByRaw< + TFields extends Record, +> extends QuerySelectionModifiers { + /** + * Order the entities by a raw SQL `ORDER BY` clause. + */ + orderByRaw?: string; +} + +export interface TableQuerySelectionModifiers { + orderBy: + | { + columnName: string; + order: OrderByOrdering; + }[] + | undefined; + offset: number | undefined; + limit: number | undefined; +} + +export interface TableQuerySelectionModifiersWithOrderByRaw extends TableQuerySelectionModifiers { + orderByRaw: string | undefined; +} + +export abstract class BasePostgresEntityDatabaseAdapter< + TFields extends Record, + TIDField extends keyof TFields, +> extends EntityDatabaseAdapter { + /** + * Fetch many objects matching the conjunction of where clauses constructed from + * specified field equality operands. + * + * @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 + * @returns array of objects matching the query + */ + async fetchManyByFieldEqualityConjunctionAsync( + queryContext: EntityQueryContext, + fieldEqualityOperands: FieldEqualityCondition[], + querySelectionModifiers: QuerySelectionModifiers, + ): Promise[]> { + const tableFieldSingleValueOperands: TableFieldSingleValueEqualityCondition[] = []; + const tableFieldMultipleValueOperands: TableFieldMultiValueEqualityCondition[] = []; + for (const operand of fieldEqualityOperands) { + if (isSingleValueFieldEqualityCondition(operand)) { + tableFieldSingleValueOperands.push({ + tableField: getDatabaseFieldForEntityField(this.entityConfiguration, operand.fieldName), + tableValue: operand.fieldValue, + }); + } else { + tableFieldMultipleValueOperands.push({ + tableField: getDatabaseFieldForEntityField(this.entityConfiguration, operand.fieldName), + tableValues: operand.fieldValues, + }); + } + } + + const results = await this.fetchManyByFieldEqualityConjunctionInternalAsync( + queryContext.getQueryInterface(), + this.entityConfiguration.tableName, + tableFieldSingleValueOperands, + tableFieldMultipleValueOperands, + this.convertToTableQueryModifiers(querySelectionModifiers), + ); + + return results.map((result) => + transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result), + ); + } + + protected abstract fetchManyByFieldEqualityConjunctionInternalAsync( + queryInterface: Knex, + tableName: string, + tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[], + tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[], + querySelectionModifiers: TableQuerySelectionModifiers, + ): Promise; + + /** + * Fetch many objects matching the raw WHERE clause. + * + * @param queryContext - query context with which to perform the fetch + * @param rawWhereClause - parameterized SQL WHERE clause with positional binding placeholders or named binding placeholders + * @param bindings - array of positional bindings or object of named bindings + * @param querySelectionModifiers - limit, offset, and orderBy for the query + * @returns array of objects matching the query + */ + async fetchManyByRawWhereClauseAsync( + queryContext: EntityQueryContext, + rawWhereClause: string, + bindings: any[] | object, + querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw, + ): Promise[]> { + const results = await this.fetchManyByRawWhereClauseInternalAsync( + queryContext.getQueryInterface(), + this.entityConfiguration.tableName, + rawWhereClause, + bindings, + this.convertToTableQueryModifiersWithOrderByRaw(querySelectionModifiers), + ); + + return results.map((result) => + transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result), + ); + } + + protected abstract fetchManyByRawWhereClauseInternalAsync( + queryInterface: Knex, + tableName: string, + rawWhereClause: string, + bindings: object | any[], + querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw, + ): Promise; + + private convertToTableQueryModifiersWithOrderByRaw( + querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw, + ): TableQuerySelectionModifiersWithOrderByRaw { + return { + ...this.convertToTableQueryModifiers(querySelectionModifiers), + orderByRaw: querySelectionModifiers.orderByRaw, + }; + } + + private convertToTableQueryModifiers( + querySelectionModifiers: QuerySelectionModifiers, + ): TableQuerySelectionModifiers { + const orderBy = querySelectionModifiers.orderBy; + return { + orderBy: + orderBy !== undefined + ? orderBy.map((orderBySpecification) => ({ + columnName: getDatabaseFieldForEntityField( + this.entityConfiguration, + orderBySpecification.fieldName, + ), + order: orderBySpecification.order, + })) + : undefined, + offset: querySelectionModifiers.offset, + limit: querySelectionModifiers.limit, + }; + } +} diff --git a/packages/entity/src/EnforcingKnexEntityLoader.ts b/packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts similarity index 96% rename from packages/entity/src/EnforcingKnexEntityLoader.ts rename to packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts index d78fe33b8..fb02c65a0 100644 --- a/packages/entity/src/EnforcingKnexEntityLoader.ts +++ b/packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts @@ -1,12 +1,11 @@ +import { EntityPrivacyPolicy, ReadonlyEntity, ViewerContext } from '@expo/entity'; + import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader'; import { FieldEqualityCondition, QuerySelectionModifiers, QuerySelectionModifiersWithOrderByRaw, -} from './EntityDatabaseAdapter'; -import { EntityPrivacyPolicy } from './EntityPrivacyPolicy'; -import { ReadonlyEntity } from './ReadonlyEntity'; -import { ViewerContext } from './ViewerContext'; +} from './BasePostgresEntityDatabaseAdapter'; /** * Enforcing knex entity loader for non-data-loader-based load methods. diff --git a/packages/entity/src/KnexEntityLoader.ts b/packages/entity-database-adapter-knex/src/KnexEntityLoader.ts similarity index 89% rename from packages/entity/src/KnexEntityLoader.ts rename to packages/entity-database-adapter-knex/src/KnexEntityLoader.ts index ab74756c3..f7ca901b9 100644 --- a/packages/entity/src/KnexEntityLoader.ts +++ b/packages/entity-database-adapter-knex/src/KnexEntityLoader.ts @@ -1,10 +1,13 @@ +import { + IEntityClass, + EntityPrivacyPolicy, + EntityQueryContext, + ReadonlyEntity, + ViewerContext, +} from '@expo/entity'; + import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader'; import { EnforcingKnexEntityLoader } from './EnforcingKnexEntityLoader'; -import { IEntityClass } from './Entity'; -import { EntityPrivacyPolicy } from './EntityPrivacyPolicy'; -import { EntityQueryContext } from './EntityQueryContext'; -import { ReadonlyEntity } from './ReadonlyEntity'; -import { ViewerContext } from './ViewerContext'; /** * The primary interface for loading entities via non-data-loader-based methods diff --git a/packages/entity/src/KnexEntityLoaderFactory.ts b/packages/entity-database-adapter-knex/src/KnexEntityLoaderFactory.ts similarity index 77% rename from packages/entity/src/KnexEntityLoaderFactory.ts rename to packages/entity-database-adapter-knex/src/KnexEntityLoaderFactory.ts index 02d34b20b..1e58920a0 100644 --- a/packages/entity/src/KnexEntityLoaderFactory.ts +++ b/packages/entity-database-adapter-knex/src/KnexEntityLoaderFactory.ts @@ -1,13 +1,16 @@ +import { + EntityCompanion, + EntityPrivacyPolicy, + EntityPrivacyPolicyEvaluationContext, + EntityQueryContext, + ReadonlyEntity, + ViewerContext, + IEntityMetricsAdapter, +} from '@expo/entity'; +import { EntityConstructionUtils } from '@expo/entity/src/EntityConstructionUtils'; + import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader'; -import { EntityCompanion } from './EntityCompanion'; -import { EntityLoaderUtils } from './EntityLoaderUtils'; -import { EntityPrivacyPolicy, EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy'; -import { EntityQueryContext } from './EntityQueryContext'; -import { ReadonlyEntity } from './ReadonlyEntity'; -import { ViewerContext } from './ViewerContext'; -import { EntityDataManager } from './internal/EntityDataManager'; import { EntityKnexDataManager } from './internal/EntityKnexDataManager'; -import { IEntityMetricsAdapter } from './metrics/IEntityMetricsAdapter'; /** * The primary entry point for loading entities via knex queries (non-data-loader methods). @@ -35,7 +38,6 @@ export class KnexEntityLoaderFactory< TPrivacyPolicy, TSelectedFields >, - private readonly dataManager: EntityDataManager, private readonly knexDataManager: EntityKnexDataManager, protected readonly metricsAdapter: IEntityMetricsAdapter, ) {} @@ -63,7 +65,7 @@ export class KnexEntityLoaderFactory< TPrivacyPolicy, TSelectedFields > { - const utils = new EntityLoaderUtils( + const constructionUtils = new EntityConstructionUtils( viewerContext, queryContext, privacyPolicyEvaluationContext, @@ -71,7 +73,6 @@ export class KnexEntityLoaderFactory< this.entityCompanion.entityCompanionDefinition.entityClass, this.entityCompanion.entityCompanionDefinition.entitySelectedFields, this.entityCompanion.privacyPolicy, - this.dataManager, this.metricsAdapter, ); @@ -79,7 +80,7 @@ export class KnexEntityLoaderFactory< queryContext, this.knexDataManager, this.metricsAdapter, - utils, + constructionUtils, ); } } diff --git a/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts b/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts index 929de3c9c..d59f2055c 100644 --- a/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts +++ b/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts @@ -1,21 +1,20 @@ +import { FieldTransformer, FieldTransformerMap } from '@expo/entity'; +import { Knex } from 'knex'; + import { - EntityDatabaseAdapter, - FieldTransformer, - FieldTransformerMap, + BasePostgresEntityDatabaseAdapter, TableFieldMultiValueEqualityCondition, TableFieldSingleValueEqualityCondition, TableQuerySelectionModifiers, TableQuerySelectionModifiersWithOrderByRaw, -} from '@expo/entity'; -import { Knex } from 'knex'; - +} from './BasePostgresEntityDatabaseAdapter'; import { JSONArrayField, MaybeJSONArrayField } from './EntityFields'; import { wrapNativePostgresCallAsync } from './errors/wrapNativePostgresCallAsync'; export class PostgresEntityDatabaseAdapter< TFields extends Record, TIDField extends keyof TFields, -> extends EntityDatabaseAdapter { +> extends BasePostgresEntityDatabaseAdapter { protected getFieldTransformerMap(): FieldTransformerMap { return new Map>([ [ diff --git a/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapterProvider.ts b/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapterProvider.ts index 4d1b9bd0d..eca4ac113 100644 --- a/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapterProvider.ts +++ b/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapterProvider.ts @@ -5,8 +5,23 @@ import { } from '@expo/entity'; import { PostgresEntityDatabaseAdapter } from './PostgresEntityDatabaseAdapter'; +import { installEntityCompanionExtensions } from './extensions/EntityCompanionExtensions'; +import { installEntityTableDataCoordinatorExtensions } from './extensions/EntityTableDataCoordinatorExtensions'; +import { installReadonlyEntityExtensions } from './extensions/ReadonlyEntityExtensions'; +import { installViewerScopedEntityCompanionExtensions } from './extensions/ViewerScopedEntityCompanionExtensions'; export class PostgresEntityDatabaseAdapterProvider implements IEntityDatabaseAdapterProvider { + getExtensionsKey(): string { + return 'PostgresEntityDatabaseAdapterProvider'; + } + + installExtensions(): void { + installEntityCompanionExtensions(); + installEntityTableDataCoordinatorExtensions(); + installViewerScopedEntityCompanionExtensions(); + installReadonlyEntityExtensions(); + } + getDatabaseAdapter, TIDField extends keyof TFields>( entityConfiguration: EntityConfiguration, ): EntityDatabaseAdapter { diff --git a/packages/entity/src/ViewerScopedKnexEntityLoaderFactory.ts b/packages/entity-database-adapter-knex/src/ViewerScopedKnexEntityLoaderFactory.ts similarity index 85% rename from packages/entity/src/ViewerScopedKnexEntityLoaderFactory.ts rename to packages/entity-database-adapter-knex/src/ViewerScopedKnexEntityLoaderFactory.ts index 08447dc81..32a60cbf4 100644 --- a/packages/entity/src/ViewerScopedKnexEntityLoaderFactory.ts +++ b/packages/entity-database-adapter-knex/src/ViewerScopedKnexEntityLoaderFactory.ts @@ -1,9 +1,13 @@ +import { + EntityPrivacyPolicy, + EntityPrivacyPolicyEvaluationContext, + EntityQueryContext, + ReadonlyEntity, + ViewerContext, +} from '@expo/entity'; + import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader'; -import { EntityPrivacyPolicy, EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy'; -import { EntityQueryContext } from './EntityQueryContext'; import { KnexEntityLoaderFactory } from './KnexEntityLoaderFactory'; -import { ReadonlyEntity } from './ReadonlyEntity'; -import { ViewerContext } from './ViewerContext'; /** * Provides a cleaner API for loading entities via knex by passing through the ViewerContext. diff --git a/packages/entity-database-adapter-knex/src/__integration-tests__/PostgresEntityIntegration-test.ts b/packages/entity-database-adapter-knex/src/__integration-tests__/PostgresEntityIntegration-test.ts index 1c3134089..ed49751a6 100644 --- a/packages/entity-database-adapter-knex/src/__integration-tests__/PostgresEntityIntegration-test.ts +++ b/packages/entity-database-adapter-knex/src/__integration-tests__/PostgresEntityIntegration-test.ts @@ -1,6 +1,5 @@ import { EntityDatabaseAdapterEmptyUpdateResultError, - OrderByOrdering, TransactionIsolationLevel, ViewerContext, } from '@expo/entity'; @@ -11,6 +10,7 @@ import { knex, Knex } from 'knex'; import nullthrows from 'nullthrows'; import { setTimeout } from 'timers/promises'; +import { OrderByOrdering } from '../BasePostgresEntityDatabaseAdapter'; import { PostgresTestEntity } from '../__testfixtures__/PostgresTestEntity'; import { PostgresTriggerTestEntity } from '../__testfixtures__/PostgresTriggerTestEntity'; import { PostgresValidatorTestEntity } from '../__testfixtures__/PostgresValidatorTestEntity'; diff --git a/packages/entity/src/__tests__/AuthorizationResultBasedKnexEntityLoader-test.ts b/packages/entity-database-adapter-knex/src/__tests__/AuthorizationResultBasedKnexEntityLoader-test.ts similarity index 55% rename from packages/entity/src/__tests__/AuthorizationResultBasedKnexEntityLoader-test.ts rename to packages/entity-database-adapter-knex/src/__tests__/AuthorizationResultBasedKnexEntityLoader-test.ts index 2d4934ca9..1fe2cf3ff 100644 --- a/packages/entity/src/__tests__/AuthorizationResultBasedKnexEntityLoader-test.ts +++ b/packages/entity-database-adapter-knex/src/__tests__/AuthorizationResultBasedKnexEntityLoader-test.ts @@ -1,26 +1,24 @@ +import { + EntityPrivacyPolicyEvaluationContext, + ViewerContext, + enforceResultsAsync, + IEntityMetricsAdapter, + EntityConstructionUtils, + EntityQueryContext, +} from '@expo/entity'; import { describe, expect, it } from '@jest/globals'; import { anyOfClass, anything, instance, mock, spy, verify, when } from 'ts-mockito'; import { v4 as uuidv4 } from 'uuid'; import { AuthorizationResultBasedKnexEntityLoader } from '../AuthorizationResultBasedKnexEntityLoader'; -import { OrderByOrdering } from '../EntityDatabaseAdapter'; -import { EntityLoaderUtils } from '../EntityLoaderUtils'; -import { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy'; -import { ViewerContext } from '../ViewerContext'; -import { enforceResultsAsync } from '../entityUtils'; -import { EntityDataManager } from '../internal/EntityDataManager'; -import { EntityKnexDataManager } from '../internal/EntityKnexDataManager'; -import { ReadThroughEntityCache } from '../internal/ReadThroughEntityCache'; -import { IEntityMetricsAdapter } from '../metrics/IEntityMetricsAdapter'; -import { NoCacheStubCacheAdapterProvider } from '../utils/__testfixtures__/StubCacheAdapter'; -import { StubDatabaseAdapter } from '../utils/__testfixtures__/StubDatabaseAdapter'; -import { StubQueryContextProvider } from '../utils/__testfixtures__/StubQueryContextProvider'; +import { OrderByOrdering } from '../BasePostgresEntityDatabaseAdapter'; import { TestEntity, - TestEntityPrivacyPolicy, testEntityConfiguration, + TestEntityPrivacyPolicy, TestFields, -} from '../utils/__testfixtures__/TestEntity'; +} from './fixtures/TestEntity'; +import { EntityKnexDataManager } from '../internal/EntityKnexDataManager'; describe(AuthorizationResultBasedKnexEntityLoader, () => { it('loads entities with loadManyByFieldEqualityConjunction', async () => { @@ -39,64 +37,39 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => { >(), ); const metricsAdapter = instance(mock()); - const queryContext = new StubQueryContextProvider().getQueryContext(); + const queryContext = instance(mock()); + + const knexDataManagerMock = + mock>(EntityKnexDataManager); const id1 = uuidv4(); const id2 = uuidv4(); - const id3 = uuidv4(); - const databaseAdapter = new StubDatabaseAdapter( - testEntityConfiguration, - StubDatabaseAdapter.convertFieldObjectsToDataStore( - testEntityConfiguration, - new Map([ - [ - testEntityConfiguration.tableName, - [ - { - customIdField: id1, - stringField: 'huh', - intField: 4, - testIndexedField: '4', - dateField: new Date(), - nullableField: null, - }, - { - customIdField: id2, - stringField: 'huh', - intField: 4, - testIndexedField: '5', - dateField: new Date(), - nullableField: null, - }, - { - customIdField: id3, - stringField: 'huh2', - intField: 4, - testIndexedField: '6', - dateField: new Date(), - nullableField: null, - }, - ], - ], - ]), + when( + knexDataManagerMock.loadManyByFieldEqualityConjunctionAsync( + queryContext, + anything(), + anything(), ), - ); - 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 knexDataManager = new EntityKnexDataManager( - databaseAdapter, - metricsAdapter, - TestEntity.name, - ); - const utils = new EntityLoaderUtils( + ).thenResolve([ + { + customIdField: id1, + stringField: 'huh', + intField: 4, + testIndexedField: '4', + dateField: new Date(), + nullableField: null, + }, + { + customIdField: id2, + stringField: 'huh', + intField: 4, + testIndexedField: '5', + dateField: new Date(), + nullableField: null, + }, + ]); + + const constructionUtils = new EntityConstructionUtils( viewerContext, queryContext, privacyPolicyEvaluationContext, @@ -104,14 +77,13 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => { TestEntity, /* entitySelectedFields */ undefined, privacyPolicy, - dataManager, metricsAdapter, ); const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader( queryContext, - knexDataManager, + instance(knexDataManagerMock), metricsAdapter, - utils, + constructionUtils, ); const entityResults = await enforceResultsAsync( knexEntityLoader.loadManyByFieldEqualityConjunctionAsync([ @@ -159,64 +131,28 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => { >(), ); const metricsAdapter = instance(mock()); - const queryContext = new StubQueryContextProvider().getQueryContext(); + const queryContext = instance(mock()); - const id1 = uuidv4(); - const id2 = uuidv4(); - const id3 = uuidv4(); - const databaseAdapter = new StubDatabaseAdapter( - testEntityConfiguration, - StubDatabaseAdapter.convertFieldObjectsToDataStore( - testEntityConfiguration, - new Map([ - [ - testEntityConfiguration.tableName, - [ - { - customIdField: id1, - stringField: 'huh', - intField: 4, - testIndexedField: '4', - dateField: new Date(), - nullableField: null, - }, - { - customIdField: id2, - stringField: 'huh', - intField: 4, - testIndexedField: '5', - dateField: new Date(), - nullableField: null, - }, - { - customIdField: id3, - stringField: 'huh2', - intField: 4, - testIndexedField: '6', - dateField: new Date(), - nullableField: null, - }, - ], - ], - ]), + const knexDataManagerMock = + mock>(EntityKnexDataManager); + when( + knexDataManagerMock.loadManyByFieldEqualityConjunctionAsync( + queryContext, + anything(), + anything(), ), - ); - 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 knexDataManager = new EntityKnexDataManager( - databaseAdapter, - metricsAdapter, - TestEntity.name, - ); - const utils = new EntityLoaderUtils( + ).thenResolve([ + { + customIdField: 'id', + stringField: 'huh', + intField: 4, + testIndexedField: '5', + dateField: new Date(), + nullableField: null, + }, + ]); + + const constructionUtils = new EntityConstructionUtils( viewerContext, queryContext, privacyPolicyEvaluationContext, @@ -224,14 +160,13 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => { TestEntity, /* entitySelectedFields */ undefined, privacyPolicy, - dataManager, metricsAdapter, ); const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader( queryContext, - knexDataManager, + instance(knexDataManagerMock), metricsAdapter, - utils, + constructionUtils, ); const result = await knexEntityLoader.loadFirstByFieldEqualityConjunctionAsync( [ @@ -280,7 +215,7 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => { >(), ); const metricsAdapter = instance(mock()); - const queryContext = new StubQueryContextProvider().getQueryContext(); + const queryContext = instance(mock()); const knexDataManagerMock = mock>(EntityKnexDataManager); @@ -303,11 +238,7 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => { ]); const knexDataManager = instance(knexDataManagerMock); - // Create a real dataManager for the EntityLoaderUtils - const dataManagerMock = mock>(EntityDataManager); - const dataManager = instance(dataManagerMock); - - const utils = new EntityLoaderUtils( + const constructionUtils = new EntityConstructionUtils( viewerContext, queryContext, privacyPolicyEvaluationContext, @@ -315,14 +246,13 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => { TestEntity, /* entitySelectedFields */ undefined, privacyPolicy, - dataManager, metricsAdapter, ); const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader( queryContext, knexDataManager, metricsAdapter, - utils, + constructionUtils, ); const result = await knexEntityLoader.loadManyByRawWhereClauseAsync('id = ?', [1], { diff --git a/packages/entity-database-adapter-knex/src/__tests__/BasePostgresEntityDatabaseAdapter-test.ts b/packages/entity-database-adapter-knex/src/__tests__/BasePostgresEntityDatabaseAdapter-test.ts new file mode 100644 index 000000000..0ce770df5 --- /dev/null +++ b/packages/entity-database-adapter-knex/src/__tests__/BasePostgresEntityDatabaseAdapter-test.ts @@ -0,0 +1,128 @@ +import { EntityQueryContext, FieldTransformerMap } from '@expo/entity'; +import { describe, expect, it } from '@jest/globals'; +import { instance, mock } from 'ts-mockito'; + +import { + BasePostgresEntityDatabaseAdapter, + TableFieldMultiValueEqualityCondition, + TableFieldSingleValueEqualityCondition, +} from '../BasePostgresEntityDatabaseAdapter'; +import { testEntityConfiguration, TestFields } from './fixtures/TestEntity'; + +class TestEntityDatabaseAdapter extends BasePostgresEntityDatabaseAdapter< + TestFields, + 'customIdField' +> { + private readonly fetchResults: object[]; + private readonly insertResults: object[]; + private readonly updateResults: object[]; + private readonly fetchEqualityConditionResults: object[]; + private readonly fetchRawWhereResults: object[]; + private readonly deleteCount: number; + + constructor({ + fetchResults = [], + insertResults = [], + updateResults = [], + fetchEqualityConditionResults = [], + fetchRawWhereResults = [], + deleteCount = 0, + }: { + fetchResults?: object[]; + insertResults?: object[]; + updateResults?: object[]; + fetchEqualityConditionResults?: object[]; + fetchRawWhereResults?: object[]; + deleteCount?: number; + }) { + super(testEntityConfiguration); + this.fetchResults = fetchResults; + this.insertResults = insertResults; + this.updateResults = updateResults; + this.fetchEqualityConditionResults = fetchEqualityConditionResults; + this.fetchRawWhereResults = fetchRawWhereResults; + this.deleteCount = deleteCount; + } + + protected getFieldTransformerMap(): FieldTransformerMap { + return new Map(); + } + + protected async fetchManyWhereInternalAsync( + _queryInterface: any, + _tableName: string, + _tableColumns: readonly string[], + _tableTuples: (readonly any[])[], + ): Promise { + return this.fetchResults; + } + + protected async fetchManyByRawWhereClauseInternalAsync( + _queryInterface: any, + _tableName: string, + _rawWhereClause: string, + _bindings: object | any[], + ): Promise { + return this.fetchRawWhereResults; + } + + protected async fetchManyByFieldEqualityConjunctionInternalAsync( + _queryInterface: any, + _tableName: string, + _tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[], + _tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[], + ): Promise { + return this.fetchEqualityConditionResults; + } + + protected async insertInternalAsync( + _queryInterface: any, + _tableName: string, + _object: object, + ): Promise { + return this.insertResults; + } + + protected async updateInternalAsync( + _queryInterface: any, + _tableName: string, + _tableIdField: string, + _id: any, + _object: object, + ): Promise { + return this.updateResults; + } + + protected async deleteInternalAsync( + _queryInterface: any, + _tableName: string, + _tableIdField: string, + _id: any, + ): Promise { + return this.deleteCount; + } +} + +describe(BasePostgresEntityDatabaseAdapter, () => { + describe('fetchManyByFieldEqualityConjunction', () => { + it('transforms object', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const adapter = new TestEntityDatabaseAdapter({ + fetchEqualityConditionResults: [{ string_field: 'hello' }], + }); + const results = await adapter.fetchManyByFieldEqualityConjunctionAsync(queryContext, [], {}); + expect(results).toEqual([{ stringField: 'hello' }]); + }); + }); + + describe('fetchManyWithRawWhereClause', () => { + it('transforms object', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const adapter = new TestEntityDatabaseAdapter({ + fetchRawWhereResults: [{ string_field: 'hello' }], + }); + const results = await adapter.fetchManyByRawWhereClauseAsync(queryContext, 'hello', [], {}); + expect(results).toEqual([{ stringField: 'hello' }]); + }); + }); +}); diff --git a/packages/entity/src/__tests__/EnforcingKnexEntityLoader-test.ts b/packages/entity-database-adapter-knex/src/__tests__/EnforcingKnexEntityLoader-test.ts similarity index 100% rename from packages/entity/src/__tests__/EnforcingKnexEntityLoader-test.ts rename to packages/entity-database-adapter-knex/src/__tests__/EnforcingKnexEntityLoader-test.ts diff --git a/packages/entity-database-adapter-knex/src/__tests__/ReadonlyEntity-test.ts b/packages/entity-database-adapter-knex/src/__tests__/ReadonlyEntity-test.ts new file mode 100644 index 000000000..e3543538a --- /dev/null +++ b/packages/entity-database-adapter-knex/src/__tests__/ReadonlyEntity-test.ts @@ -0,0 +1,27 @@ +import { ReadonlyEntity, ViewerContext } from '@expo/entity'; +import { describe, expect, it } from '@jest/globals'; + +import { AuthorizationResultBasedKnexEntityLoader } from '../AuthorizationResultBasedKnexEntityLoader'; +import { EnforcingKnexEntityLoader } from '../EnforcingKnexEntityLoader'; +import { TestEntity } from './fixtures/TestEntity'; +import { createUnitTestPostgresEntityCompanionProvider } from './fixtures/createUnitTestPostgresEntityCompanionProvider'; + +describe(ReadonlyEntity, () => { + describe('knexLoader', () => { + it('creates a new EnforcingKnexEntityLoader', async () => { + const companionProvider = createUnitTestPostgresEntityCompanionProvider(); + const viewerContext = new ViewerContext(companionProvider); + expect(TestEntity.knexLoader(viewerContext)).toBeInstanceOf(EnforcingKnexEntityLoader); + }); + }); + + describe('knexLoaderWithAuthorizationResults', () => { + it('creates a new AuthorizationResultBasedKnexEntityLoader', async () => { + const companionProvider = createUnitTestPostgresEntityCompanionProvider(); + const viewerContext = new ViewerContext(companionProvider); + expect(TestEntity.knexLoaderWithAuthorizationResults(viewerContext)).toBeInstanceOf( + AuthorizationResultBasedKnexEntityLoader, + ); + }); + }); +}); diff --git a/packages/entity-database-adapter-knex/src/__tests__/fixtures/StubPostgresDatabaseAdapter.ts b/packages/entity-database-adapter-knex/src/__tests__/fixtures/StubPostgresDatabaseAdapter.ts new file mode 100644 index 000000000..3ad566fee --- /dev/null +++ b/packages/entity-database-adapter-knex/src/__tests__/fixtures/StubPostgresDatabaseAdapter.ts @@ -0,0 +1,276 @@ +import { + computeIfAbsent, + EntityConfiguration, + FieldTransformerMap, + getDatabaseFieldForEntityField, + IntField, + mapMap, + StringField, + transformFieldsToDatabaseObject, +} from '@expo/entity'; +import invariant from 'invariant'; +import { v7 as uuidv7 } from 'uuid'; + +import { + BasePostgresEntityDatabaseAdapter, + OrderByOrdering, + TableFieldMultiValueEqualityCondition, + TableFieldSingleValueEqualityCondition, + TableQuerySelectionModifiers, +} from '../../BasePostgresEntityDatabaseAdapter'; + +export class StubPostgresDatabaseAdapter< + TFields extends Record, + TIDField extends keyof TFields, +> extends BasePostgresEntityDatabaseAdapter { + constructor( + private readonly entityConfiguration2: EntityConfiguration, + private readonly dataStore: Map[]>, + ) { + super(entityConfiguration2); + } + + public static convertFieldObjectsToDataStore< + TFields extends Record, + TIDField extends keyof TFields, + >( + entityConfiguration: EntityConfiguration, + dataStore: Map[]>, + ): Map[]> { + return mapMap(dataStore, (objectsForTable) => + objectsForTable.map((objectForTable) => + transformFieldsToDatabaseObject(entityConfiguration, new Map(), objectForTable), + ), + ); + } + + public getObjectCollectionForTable(tableName: string): { [key: string]: any }[] { + return computeIfAbsent(this.dataStore, tableName, () => []); + } + + protected getFieldTransformerMap(): FieldTransformerMap { + return new Map(); + } + + private static uniqBy(a: T[], keyExtractor: (k: T) => string): T[] { + const seen = new Set(); + return a.filter((item) => { + const k = keyExtractor(item); + if (seen.has(k)) { + return false; + } + seen.add(k); + return true; + }); + } + + protected async fetchManyWhereInternalAsync( + _queryInterface: any, + tableName: string, + tableColumns: readonly string[], + tableTuples: (readonly any[])[], + ): Promise { + const objectCollection = this.getObjectCollectionForTable(tableName); + const results = StubPostgresDatabaseAdapter.uniqBy(tableTuples, (tuple) => + tuple.join(':'), + ).reduce( + (acc, tableTuple) => { + return acc.concat( + objectCollection.filter((obj) => { + return tableColumns.every((tableColumn, index) => { + return obj[tableColumn] === tableTuple[index]; + }); + }), + ); + }, + [] as { [key: string]: any }[], + ); + return [...results]; + } + + private static compareByOrderBys( + orderBys: { + columnName: string; + order: OrderByOrdering; + }[], + objectA: { [key: string]: any }, + objectB: { [key: string]: any }, + ): 0 | 1 | -1 { + if (orderBys.length === 0) { + return 0; + } + + const currentOrderBy = orderBys[0]!; + const aField = objectA[currentOrderBy.columnName]; + const bField = objectB[currentOrderBy.columnName]; + switch (currentOrderBy.order) { + case OrderByOrdering.DESCENDING: { + // simulate NULLS FIRST for DESC + if (aField === null && bField === null) { + return 0; + } else if (aField === null) { + return -1; + } else if (bField === null) { + return 1; + } + + return aField > bField + ? -1 + : aField < bField + ? 1 + : this.compareByOrderBys(orderBys.slice(1), objectA, objectB); + } + case OrderByOrdering.ASCENDING: { + // simulate NULLS LAST for ASC + if (aField === null && bField === null) { + return 0; + } else if (bField === null) { + return -1; + } else if (aField === null) { + return 1; + } + + return bField > aField + ? -1 + : bField < aField + ? 1 + : this.compareByOrderBys(orderBys.slice(1), objectA, objectB); + } + } + } + + protected async fetchManyByFieldEqualityConjunctionInternalAsync( + _queryInterface: any, + tableName: string, + tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[], + tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[], + querySelectionModifiers: TableQuerySelectionModifiers, + ): Promise { + let filteredObjects = this.getObjectCollectionForTable(tableName); + for (const { tableField, tableValue } of tableFieldSingleValueEqualityOperands) { + filteredObjects = filteredObjects.filter((obj) => obj[tableField] === tableValue); + } + + for (const { tableField, tableValues } of tableFieldMultiValueEqualityOperands) { + filteredObjects = filteredObjects.filter((obj) => tableValues.includes(obj[tableField])); + } + + const orderBy = querySelectionModifiers.orderBy; + if (orderBy !== undefined) { + filteredObjects = filteredObjects.sort((a, b) => + StubPostgresDatabaseAdapter.compareByOrderBys(orderBy, a, b), + ); + } + + const offset = querySelectionModifiers.offset; + if (offset !== undefined) { + filteredObjects = filteredObjects.slice(offset); + } + + const limit = querySelectionModifiers.limit; + if (limit !== undefined) { + filteredObjects = filteredObjects.slice(0, 0 + limit); + } + + return filteredObjects; + } + + protected fetchManyByRawWhereClauseInternalAsync( + _queryInterface: any, + _tableName: string, + _rawWhereClause: string, + _bindings: object | any[], + _querySelectionModifiers: TableQuerySelectionModifiers, + ): Promise { + throw new Error('Raw WHERE clauses not supported for StubDatabaseAdapter'); + } + + private generateRandomID(): any { + const idSchemaField = this.entityConfiguration2.schema.get(this.entityConfiguration2.idField); + invariant( + idSchemaField, + `No schema field found for ${String(this.entityConfiguration2.idField)}`, + ); + if (idSchemaField instanceof StringField) { + return uuidv7(); + } else if (idSchemaField instanceof IntField) { + return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); + } else { + throw new Error( + `Unsupported ID type for StubDatabaseAdapter: ${idSchemaField.constructor.name}`, + ); + } + } + + protected async insertInternalAsync( + _queryInterface: any, + tableName: string, + object: object, + ): Promise { + const objectCollection = this.getObjectCollectionForTable(tableName); + + const idField = getDatabaseFieldForEntityField( + this.entityConfiguration2, + this.entityConfiguration2.idField, + ); + const objectToInsert = { + [idField]: this.generateRandomID(), + ...object, + }; + objectCollection.push(objectToInsert); + return [objectToInsert]; + } + + protected async updateInternalAsync( + _queryInterface: any, + tableName: string, + tableIdField: string, + id: any, + object: object, + ): Promise { + // SQL does not support empty updates, mirror behavior here for better test simulation + if (Object.keys(object).length === 0) { + throw new Error(`Empty update (${tableIdField} = ${id})`); + } + + const objectCollection = this.getObjectCollectionForTable(tableName); + + const objectIndex = objectCollection.findIndex((obj) => { + return obj[tableIdField] === id; + }); + + // SQL updates to a nonexistent row succeed but affect 0 rows, + // mirror that behavior here for better test simulation + if (objectIndex < 0) { + return []; + } + + objectCollection[objectIndex] = { + ...objectCollection[objectIndex], + ...object, + }; + return [objectCollection[objectIndex]]; + } + + protected async deleteInternalAsync( + _queryInterface: any, + tableName: string, + tableIdField: string, + id: any, + ): Promise { + const objectCollection = this.getObjectCollectionForTable(tableName); + + const objectIndex = objectCollection.findIndex((obj) => { + return obj[tableIdField] === id; + }); + + // SQL deletes to a nonexistent row succeed and affect 0 rows, + // mirror that behavior here for better test simulation + if (objectIndex < 0) { + return 0; + } + + objectCollection.splice(objectIndex, 1); + return 1; + } +} diff --git a/packages/entity-database-adapter-knex/src/__tests__/fixtures/StubPostgresDatabaseAdapterProvider.ts b/packages/entity-database-adapter-knex/src/__tests__/fixtures/StubPostgresDatabaseAdapterProvider.ts new file mode 100644 index 000000000..be03094df --- /dev/null +++ b/packages/entity-database-adapter-knex/src/__tests__/fixtures/StubPostgresDatabaseAdapterProvider.ts @@ -0,0 +1,32 @@ +import { + EntityConfiguration, + EntityDatabaseAdapter, + IEntityDatabaseAdapterProvider, +} from '@expo/entity'; + +import { StubPostgresDatabaseAdapter } from './StubPostgresDatabaseAdapter'; +import { installEntityCompanionExtensions } from '../../extensions/EntityCompanionExtensions'; +import { installEntityTableDataCoordinatorExtensions } from '../../extensions/EntityTableDataCoordinatorExtensions'; +import { installReadonlyEntityExtensions } from '../../extensions/ReadonlyEntityExtensions'; +import { installViewerScopedEntityCompanionExtensions } from '../../extensions/ViewerScopedEntityCompanionExtensions'; + +export class StubPostgresDatabaseAdapterProvider implements IEntityDatabaseAdapterProvider { + getExtensionsKey(): string { + return 'StubPostgresDatabaseAdapterProvider'; + } + + installExtensions(): void { + installEntityCompanionExtensions(); + installEntityTableDataCoordinatorExtensions(); + installViewerScopedEntityCompanionExtensions(); + installReadonlyEntityExtensions(); + } + + private readonly objectCollection = new Map(); + + getDatabaseAdapter, TIDField extends keyof TFields>( + entityConfiguration: EntityConfiguration, + ): EntityDatabaseAdapter { + return new StubPostgresDatabaseAdapter(entityConfiguration, this.objectCollection); + } +} diff --git a/packages/entity-database-adapter-knex/src/__tests__/fixtures/TestEntity.ts b/packages/entity-database-adapter-knex/src/__tests__/fixtures/TestEntity.ts new file mode 100644 index 000000000..dc6aecd25 --- /dev/null +++ b/packages/entity-database-adapter-knex/src/__tests__/fixtures/TestEntity.ts @@ -0,0 +1,130 @@ +import { + Entity, + EntityCompanionDefinition, + EntityConfiguration, + DateField, + IntField, + StringField, + UUIDField, + EntityPrivacyPolicy, + ViewerContext, + AlwaysAllowPrivacyPolicyRule, +} from '@expo/entity'; +import { result, Result } from '@expo/results'; + +export type TestFields = { + customIdField: string; + testIndexedField: string; + stringField: string; + intField: number; + dateField: Date; + nullableField: string | null; +}; + +export const testEntityConfiguration = 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', + compositeFieldDefinitions: [ + { compositeField: ['stringField', 'intField'], cache: false }, + { compositeField: ['stringField', 'testIndexedField'], cache: true }, + { compositeField: ['nullableField', 'testIndexedField'], cache: true }, + ], +}); + +export class TestEntityPrivacyPolicy extends EntityPrivacyPolicy< + TestFields, + 'customIdField', + ViewerContext, + TestEntity +> { + 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 class TestEntity extends Entity { + static defineCompanionDefinition(): EntityCompanionDefinition< + TestFields, + 'customIdField', + ViewerContext, + TestEntity, + TestEntityPrivacyPolicy + > { + return { + entityClass: TestEntity, + entityConfiguration: testEntityConfiguration, + privacyPolicyClass: TestEntityPrivacyPolicy, + }; + } + + getBlah(): string { + return 'Hello World!'; + } + + static async helloAsync( + viewerContext: ViewerContext, + testValue: string, + ): Promise> { + const fields = { + customIdField: testValue, + testIndexedField: 'hello', + stringField: 'hello', + intField: 1, + dateField: new Date(), + nullableField: null, + }; + return result( + new TestEntity({ + viewerContext, + id: testValue, + databaseFields: fields, + selectedFields: fields, + }), + ); + } + + static async returnErrorAsync(_viewerContext: ViewerContext): Promise> { + return result(new Error('return entity')); + } + + static async throwErrorAsync(_viewerContext: ViewerContext): Promise> { + throw new Error('threw entity'); + } + + static async nonResultAsync(_viewerContext: ViewerContext, testValue: string): Promise { + return testValue; + } +} diff --git a/packages/entity-database-adapter-knex/src/__tests__/fixtures/createUnitTestPostgresEntityCompanionProvider.ts b/packages/entity-database-adapter-knex/src/__tests__/fixtures/createUnitTestPostgresEntityCompanionProvider.ts new file mode 100644 index 000000000..98a672cad --- /dev/null +++ b/packages/entity-database-adapter-knex/src/__tests__/fixtures/createUnitTestPostgresEntityCompanionProvider.ts @@ -0,0 +1,42 @@ +import { + EntityCompanionProvider, + IEntityMetricsAdapter, + NoOpEntityMetricsAdapter, +} from '@expo/entity'; +import { + InMemoryFullCacheStubCacheAdapterProvider, + StubQueryContextProvider, +} from '@expo/entity-testing-utils'; + +import { StubPostgresDatabaseAdapterProvider } from './StubPostgresDatabaseAdapterProvider'; + +const queryContextProvider = new StubQueryContextProvider(); + +/** + * Entity companion provider for use in knex unit tests. All database and cache implementations + * are replaced with in-memory simulations. + */ +export const createUnitTestPostgresEntityCompanionProvider = ( + metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter(), +): EntityCompanionProvider => { + return new EntityCompanionProvider( + metricsAdapter, + new Map([ + [ + 'postgres', + { + adapterProvider: new StubPostgresDatabaseAdapterProvider(), + queryContextProvider, + }, + ], + ]), + new Map([ + [ + 'redis', + { + cacheAdapterProvider: new InMemoryFullCacheStubCacheAdapterProvider(), + }, + ], + ]), + ); +}; diff --git a/packages/entity-database-adapter-knex/src/extensions/EntityCompanionExtensions.ts b/packages/entity-database-adapter-knex/src/extensions/EntityCompanionExtensions.ts new file mode 100644 index 000000000..44fac0c19 --- /dev/null +++ b/packages/entity-database-adapter-knex/src/extensions/EntityCompanionExtensions.ts @@ -0,0 +1,81 @@ +import { EntityCompanion, EntityPrivacyPolicy, ReadonlyEntity, ViewerContext } from '@expo/entity'; + +import { KnexEntityLoaderFactory } from '../KnexEntityLoaderFactory'; + +const KNEX_LOADER_FACTORY = Symbol('knexLoaderFactory'); + +declare module '@expo/entity' { + interface EntityCompanion< + TFields extends Record, + TIDField extends keyof NonNullable>, + TViewerContext extends ViewerContext, + TEntity extends ReadonlyEntity, + TPrivacyPolicy extends EntityPrivacyPolicy< + TFields, + TIDField, + TViewerContext, + TEntity, + TSelectedFields + >, + TSelectedFields extends keyof TFields, + > { + [KNEX_LOADER_FACTORY]: + | KnexEntityLoaderFactory< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + > + | undefined; + + getKnexLoaderFactory(): KnexEntityLoaderFactory< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >; + } +} + +export function installEntityCompanionExtensions(): void { + EntityCompanion.prototype.getKnexLoaderFactory = function < + TFields extends Record, + TIDField extends keyof NonNullable>, + TViewerContext extends ViewerContext, + TEntity extends ReadonlyEntity, + TPrivacyPolicy extends EntityPrivacyPolicy< + TFields, + TIDField, + TViewerContext, + TEntity, + TSelectedFields + >, + TSelectedFields extends keyof TFields, + >( + this: EntityCompanion< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >, + ): KnexEntityLoaderFactory< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + > { + return (this[KNEX_LOADER_FACTORY] ??= new KnexEntityLoaderFactory( + this, + this.tableDataCoordinator.getKnexDataManager(), + this.metricsAdapter, + )); + }; +} diff --git a/packages/entity-database-adapter-knex/src/extensions/EntityTableDataCoordinatorExtensions.ts b/packages/entity-database-adapter-knex/src/extensions/EntityTableDataCoordinatorExtensions.ts new file mode 100644 index 000000000..3cb007a6a --- /dev/null +++ b/packages/entity-database-adapter-knex/src/extensions/EntityTableDataCoordinatorExtensions.ts @@ -0,0 +1,43 @@ +import { EntityDatabaseAdapter, EntityTableDataCoordinator } from '@expo/entity'; +import assert from 'assert'; + +import { BasePostgresEntityDatabaseAdapter } from '../BasePostgresEntityDatabaseAdapter'; +import { EntityKnexDataManager } from '../internal/EntityKnexDataManager'; + +const KNEX_DATA_MANAGER = Symbol('knexDataManager'); + +declare module '@expo/entity' { + interface EntityTableDataCoordinator< + TFields extends Record, + TIDField extends keyof TFields, + > { + [KNEX_DATA_MANAGER]: EntityKnexDataManager | undefined; + getKnexDataManager(): EntityKnexDataManager; + } +} + +function requireBasePostgresAdapter< + TFields extends Record, + TIDField extends keyof TFields, +>( + databaseAdapter: EntityDatabaseAdapter, +): BasePostgresEntityDatabaseAdapter { + assert( + databaseAdapter instanceof BasePostgresEntityDatabaseAdapter, + `Cannot create KnexDataManager for EntityTableDataCoordinator with non-Postgres database adapter.`, + ); + return databaseAdapter; +} + +export function installEntityTableDataCoordinatorExtensions(): void { + EntityTableDataCoordinator.prototype.getKnexDataManager = function < + TFields extends Record, + TIDField extends keyof TFields, + >(this: EntityTableDataCoordinator): EntityKnexDataManager { + return (this[KNEX_DATA_MANAGER] ??= new EntityKnexDataManager( + requireBasePostgresAdapter(this.databaseAdapter), + this.metricsAdapter, + this.entityClassName, + )); + }; +} diff --git a/packages/entity-database-adapter-knex/src/extensions/ReadonlyEntityExtensions.ts b/packages/entity-database-adapter-knex/src/extensions/ReadonlyEntityExtensions.ts new file mode 100644 index 000000000..adfa1b766 --- /dev/null +++ b/packages/entity-database-adapter-knex/src/extensions/ReadonlyEntityExtensions.ts @@ -0,0 +1,179 @@ +import { + EntityPrivacyPolicy, + EntityQueryContext, + IEntityClass, + ReadonlyEntity, + ViewerContext, +} from '@expo/entity'; + +import { AuthorizationResultBasedKnexEntityLoader } from '../AuthorizationResultBasedKnexEntityLoader'; +import { EnforcingKnexEntityLoader } from '../EnforcingKnexEntityLoader'; +import { KnexEntityLoader } from '../KnexEntityLoader'; + +declare module '@expo/entity' { + namespace ReadonlyEntity { + export function knexLoader< + TMFields extends object, + TMIDField extends keyof NonNullable>, + TMViewerContext extends ViewerContext, + TMViewerContext2 extends TMViewerContext, + TMEntity extends ReadonlyEntity, + TMPrivacyPolicy extends EntityPrivacyPolicy< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMSelectedFields + >, + TMSelectedFields extends keyof TMFields = keyof TMFields, + >( + this: IEntityClass< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + >, + viewerContext: TMViewerContext2, + queryContext?: EntityQueryContext, + ): EnforcingKnexEntityLoader< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + >; + + export function knexLoaderWithAuthorizationResults< + TMFields extends object, + TMIDField extends keyof NonNullable>, + TMViewerContext extends ViewerContext, + TMViewerContext2 extends TMViewerContext, + TMEntity extends ReadonlyEntity, + TMPrivacyPolicy extends EntityPrivacyPolicy< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMSelectedFields + >, + TMSelectedFields extends keyof TMFields = keyof TMFields, + >( + this: IEntityClass< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + >, + viewerContext: TMViewerContext2, + queryContext?: EntityQueryContext, + ): AuthorizationResultBasedKnexEntityLoader< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + >; + } +} + +class ReadonlyEntityExtensions { + /** + * Vend knex loader for loading entities via non-data-loader methods in a given query context. + * @param viewerContext - viewer context of loading user + * @param queryContext - query context in which to perform the load + */ + static knexLoader< + TMFields extends object, + TMIDField extends keyof NonNullable>, + TMViewerContext extends ViewerContext, + TMViewerContext2 extends TMViewerContext, + TMEntity extends ReadonlyEntity, + TMPrivacyPolicy extends EntityPrivacyPolicy< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMSelectedFields + >, + TMSelectedFields extends keyof TMFields = keyof TMFields, + >( + this: IEntityClass< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + >, + viewerContext: TMViewerContext2, + queryContext: EntityQueryContext = viewerContext + .getViewerScopedEntityCompanionForClass(this) + .getQueryContextProvider() + .getQueryContext(), + ): EnforcingKnexEntityLoader< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + > { + return new KnexEntityLoader(viewerContext, queryContext, this).enforcing(); + } + + /** + * Vend knex loader for loading entities via non-data-loader methods in a given query context. + * @param viewerContext - viewer context of loading user + * @param queryContext - query context in which to perform the load + */ + static knexLoaderWithAuthorizationResults< + TMFields extends object, + TMIDField extends keyof NonNullable>, + TMViewerContext extends ViewerContext, + TMViewerContext2 extends TMViewerContext, + TMEntity extends ReadonlyEntity, + TMPrivacyPolicy extends EntityPrivacyPolicy< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMSelectedFields + >, + TMSelectedFields extends keyof TMFields = keyof TMFields, + >( + this: IEntityClass< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + >, + viewerContext: TMViewerContext2, + queryContext: EntityQueryContext = viewerContext + .getViewerScopedEntityCompanionForClass(this) + .getQueryContextProvider() + .getQueryContext(), + ): AuthorizationResultBasedKnexEntityLoader< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + > { + return new KnexEntityLoader(viewerContext, queryContext, this).withAuthorizationResults(); + } +} + +export function installReadonlyEntityExtensions(): void { + ReadonlyEntity.knexLoader = ReadonlyEntityExtensions.knexLoader; + ReadonlyEntity.knexLoaderWithAuthorizationResults = + ReadonlyEntityExtensions.knexLoaderWithAuthorizationResults; +} diff --git a/packages/entity-database-adapter-knex/src/extensions/ViewerScopedEntityCompanionExtensions.ts b/packages/entity-database-adapter-knex/src/extensions/ViewerScopedEntityCompanionExtensions.ts new file mode 100644 index 000000000..7c2198fd2 --- /dev/null +++ b/packages/entity-database-adapter-knex/src/extensions/ViewerScopedEntityCompanionExtensions.ts @@ -0,0 +1,72 @@ +import { + EntityPrivacyPolicy, + ReadonlyEntity, + ViewerContext, + ViewerScopedEntityCompanion, +} from '@expo/entity'; + +import { ViewerScopedKnexEntityLoaderFactory } from '../ViewerScopedKnexEntityLoaderFactory'; + +declare module '@expo/entity' { + interface ViewerScopedEntityCompanion< + TFields extends Record, + TIDField extends keyof NonNullable>, + TViewerContext extends ViewerContext, + TEntity extends ReadonlyEntity, + TPrivacyPolicy extends EntityPrivacyPolicy< + TFields, + TIDField, + TViewerContext, + TEntity, + TSelectedFields + >, + TSelectedFields extends keyof TFields, + > { + getKnexLoaderFactory(): ViewerScopedKnexEntityLoaderFactory< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >; + } +} + +export function installViewerScopedEntityCompanionExtensions(): void { + ViewerScopedEntityCompanion.prototype.getKnexLoaderFactory = function < + TFields extends Record, + TIDField extends keyof NonNullable>, + TViewerContext extends ViewerContext, + TEntity extends ReadonlyEntity, + TPrivacyPolicy extends EntityPrivacyPolicy< + TFields, + TIDField, + TViewerContext, + TEntity, + TSelectedFields + >, + TSelectedFields extends keyof TFields, + >( + this: ViewerScopedEntityCompanion< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >, + ): ViewerScopedKnexEntityLoaderFactory< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + > { + return new ViewerScopedKnexEntityLoaderFactory( + this.entityCompanion.getKnexLoaderFactory(), + this.viewerContext, + ); + }; +} diff --git a/packages/entity-database-adapter-knex/src/index.ts b/packages/entity-database-adapter-knex/src/index.ts index f4e3c95d6..0b31d5dfa 100644 --- a/packages/entity-database-adapter-knex/src/index.ts +++ b/packages/entity-database-adapter-knex/src/index.ts @@ -4,8 +4,20 @@ * @module @expo/entity-database-adapter-knex */ +export * from './AuthorizationResultBasedKnexEntityLoader'; +export * from './BasePostgresEntityDatabaseAdapter'; +export * from './EnforcingKnexEntityLoader'; export * from './EntityFields'; +export * from './KnexEntityLoader'; +export * from './KnexEntityLoaderFactory'; export * from './PostgresEntityDatabaseAdapter'; export * from './PostgresEntityDatabaseAdapterProvider'; export * from './PostgresEntityQueryContextProvider'; +export * from './ViewerScopedKnexEntityLoaderFactory'; export * from './errors/wrapNativePostgresCallAsync'; +export * from './extensions/EntityCompanionExtensions'; +export * from './extensions/EntityTableDataCoordinatorExtensions'; +export * from './extensions/ReadonlyEntityExtensions'; +export * from './extensions/ViewerScopedEntityCompanionExtensions'; +export * from './internal/EntityKnexDataManager'; +export * from './utils/EntityPrivacyUtils'; diff --git a/packages/entity/src/internal/EntityKnexDataManager.ts b/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts similarity index 87% rename from packages/entity/src/internal/EntityKnexDataManager.ts rename to packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts index 3a18761cb..ae5c791eb 100644 --- a/packages/entity/src/internal/EntityKnexDataManager.ts +++ b/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts @@ -1,12 +1,16 @@ import { - EntityDatabaseAdapter, + EntityQueryContext, + timeAndLogLoadEventAsync, + EntityMetricsLoadType, + IEntityMetricsAdapter, +} from '@expo/entity'; + +import { + BasePostgresEntityDatabaseAdapter, FieldEqualityCondition, QuerySelectionModifiers, QuerySelectionModifiersWithOrderByRaw, -} from '../EntityDatabaseAdapter'; -import { EntityQueryContext } from '../EntityQueryContext'; -import { timeAndLogLoadEventAsync } from '../metrics/EntityMetricsUtils'; -import { EntityMetricsLoadType, IEntityMetricsAdapter } from '../metrics/IEntityMetricsAdapter'; +} from '../BasePostgresEntityDatabaseAdapter'; /** * A knex data manager is responsible for handling non-dataloader-based @@ -19,7 +23,7 @@ export class EntityKnexDataManager< TIDField extends keyof TFields, > { constructor( - private readonly databaseAdapter: EntityDatabaseAdapter, + private readonly databaseAdapter: BasePostgresEntityDatabaseAdapter, private readonly metricsAdapter: IEntityMetricsAdapter, private readonly entityClassName: string, ) {} diff --git a/packages/entity/src/internal/__tests__/EntityKnexDataManager-test.ts b/packages/entity-database-adapter-knex/src/internal/__tests__/EntityKnexDataManager-test.ts similarity index 63% rename from packages/entity/src/internal/__tests__/EntityKnexDataManager-test.ts rename to packages/entity-database-adapter-knex/src/internal/__tests__/EntityKnexDataManager-test.ts index 2fe6d40aa..704451987 100644 --- a/packages/entity/src/internal/__tests__/EntityKnexDataManager-test.ts +++ b/packages/entity-database-adapter-knex/src/internal/__tests__/EntityKnexDataManager-test.ts @@ -1,4 +1,11 @@ -import { describe, expect, it, jest } from '@jest/globals'; +import { + EntityMetricsLoadType, + EntityQueryContext, + IEntityMetricsAdapter, + NoOpEntityMetricsAdapter, +} from '@expo/entity'; +import { StubQueryContextProvider } from '@expo/entity-testing-utils'; +import { describe, expect, it } from '@jest/globals'; import { anyNumber, anyString, @@ -7,74 +14,49 @@ import { instance, mock, resetCalls, - spy, verify, when, } from 'ts-mockito'; -import { EntityMetricsLoadType, IEntityMetricsAdapter } from '../../metrics/IEntityMetricsAdapter'; -import { NoOpEntityMetricsAdapter } from '../../metrics/NoOpEntityMetricsAdapter'; -import { StubDatabaseAdapter } from '../../utils/__testfixtures__/StubDatabaseAdapter'; -import { StubQueryContextProvider } from '../../utils/__testfixtures__/StubQueryContextProvider'; -import { - TestEntity, - testEntityConfiguration, - TestFields, -} from '../../utils/__testfixtures__/TestEntity'; +import { PostgresEntityDatabaseAdapter } from '../../PostgresEntityDatabaseAdapter'; +import { TestEntity, TestFields } from '../../__tests__/fixtures/TestEntity'; import { EntityKnexDataManager } from '../EntityKnexDataManager'; -const getObjects = (): Map => - new Map([ - [ - testEntityConfiguration.tableName, - [ - { - customIdField: '1', - testIndexedField: 'unique1', - stringField: 'hello', - intField: 1, - dateField: new Date(), - nullableField: null, - }, - { - customIdField: '2', - testIndexedField: 'unique2', - stringField: 'hello', - intField: 1, - dateField: new Date(), - nullableField: null, - }, - { - customIdField: '3', - testIndexedField: 'unique3', - stringField: 'world', - intField: 1, - dateField: new Date(), - nullableField: null, - }, - ], - ], - ]); - describe(EntityKnexDataManager, () => { it('loads by field equality conjunction and does not cache', async () => { - const objects = getObjects(); - const dataStore = StubDatabaseAdapter.convertFieldObjectsToDataStore( - testEntityConfiguration, - objects, - ); - const databaseAdapter = new StubDatabaseAdapter( - testEntityConfiguration, - dataStore, + const queryContext = instance(mock()); + const databaseAdapterMock = mock>( + PostgresEntityDatabaseAdapter, ); + when( + databaseAdapterMock.fetchManyByFieldEqualityConjunctionAsync( + queryContext, + anything(), + anything(), + ), + ).thenResolve([ + { + customIdField: '1', + testIndexedField: 'unique1', + stringField: 'hello', + intField: 1, + dateField: new Date(), + nullableField: null, + }, + { + customIdField: '2', + testIndexedField: 'unique2', + stringField: 'hello', + intField: 1, + dateField: new Date(), + nullableField: null, + }, + ]); const entityDataManager = new EntityKnexDataManager( - databaseAdapter, + instance(databaseAdapterMock), new NoOpEntityMetricsAdapter(), TestEntity.name, ); - const queryContext = new StubQueryContextProvider().getQueryContext(); - - const dbSpy = jest.spyOn(databaseAdapter, 'fetchManyByFieldEqualityConjunctionAsync'); const entityDatas = await entityDataManager.loadManyByFieldEqualityConjunctionAsync( queryContext, @@ -93,31 +75,55 @@ describe(EntityKnexDataManager, () => { expect(entityDatas).toHaveLength(2); - expect(dbSpy).toHaveBeenCalled(); - - dbSpy.mockReset(); + verify( + databaseAdapterMock.fetchManyByFieldEqualityConjunctionAsync( + queryContext, + anything(), + anything(), + ), + ).once(); }); describe('metrics', () => { it('records metrics appropriately outside of transactions', async () => { const metricsAdapterMock = mock(); const metricsAdapter = instance(metricsAdapterMock); + const queryContext = new StubQueryContextProvider().getQueryContext(); - const objects = getObjects(); - const dataStore = StubDatabaseAdapter.convertFieldObjectsToDataStore( - testEntityConfiguration, - objects, - ); - const databaseAdapter = new StubDatabaseAdapter( - testEntityConfiguration, - dataStore, + const databaseAdapterMock = mock>( + PostgresEntityDatabaseAdapter, ); + + when( + databaseAdapterMock.fetchManyByFieldEqualityConjunctionAsync( + anything(), + anything(), + anything(), + ), + ).thenResolve([ + { + customIdField: '1', + testIndexedField: 'unique1', + stringField: 'hello', + intField: 1, + dateField: new Date(), + nullableField: null, + }, + ]); + when( + databaseAdapterMock.fetchManyByRawWhereClauseAsync( + anything(), + anyString(), + anything(), + anything(), + ), + ).thenResolve([]); + const entityDataManager = new EntityKnexDataManager( - databaseAdapter, + instance(databaseAdapterMock), metricsAdapter, TestEntity.name, ); - const queryContext = new StubQueryContextProvider().getQueryContext(); await entityDataManager.loadManyByFieldEqualityConjunctionAsync( queryContext, @@ -143,15 +149,6 @@ describe(EntityKnexDataManager, () => { resetCalls(metricsAdapterMock); - const databaseAdapterSpy = spy(databaseAdapter); - when( - databaseAdapterSpy.fetchManyByRawWhereClauseAsync( - anything(), - anyString(), - anything(), - anything(), - ), - ).thenResolve([]); await entityDataManager.loadManyByRawWhereClauseAsync(queryContext, '', [], {}); verify( metricsAdapterMock.logDataManagerLoadEvent( @@ -172,17 +169,37 @@ describe(EntityKnexDataManager, () => { const metricsAdapterMock = mock(); const metricsAdapter = instance(metricsAdapterMock); - const objects = getObjects(); - const dataStore = StubDatabaseAdapter.convertFieldObjectsToDataStore( - testEntityConfiguration, - objects, - ); - const databaseAdapter = new StubDatabaseAdapter( - testEntityConfiguration, - dataStore, + const databaseAdapterMock = mock>( + PostgresEntityDatabaseAdapter, ); + + when( + databaseAdapterMock.fetchManyByFieldEqualityConjunctionAsync( + anything(), + anything(), + anything(), + ), + ).thenResolve([ + { + customIdField: '1', + testIndexedField: 'unique1', + stringField: 'hello', + intField: 1, + dateField: new Date(), + nullableField: null, + }, + ]); + when( + databaseAdapterMock.fetchManyByRawWhereClauseAsync( + anything(), + anyString(), + anything(), + anything(), + ), + ).thenResolve([]); + const entityDataManager = new EntityKnexDataManager( - databaseAdapter, + instance(databaseAdapterMock), metricsAdapter, TestEntity.name, ); @@ -212,15 +229,6 @@ describe(EntityKnexDataManager, () => { resetCalls(metricsAdapterMock); - const databaseAdapterSpy = spy(databaseAdapter); - when( - databaseAdapterSpy.fetchManyByRawWhereClauseAsync( - anything(), - anyString(), - anything(), - anything(), - ), - ).thenResolve([]); await entityDataManager.loadManyByRawWhereClauseAsync(queryContext, '', [], {}); verify( metricsAdapterMock.logDataManagerLoadEvent( diff --git a/packages/entity/src/utils/EntityPrivacyUtils.ts b/packages/entity-database-adapter-knex/src/utils/EntityPrivacyUtils.ts similarity index 95% rename from packages/entity/src/utils/EntityPrivacyUtils.ts rename to packages/entity-database-adapter-knex/src/utils/EntityPrivacyUtils.ts index 28fb7cff3..27cdf686a 100644 --- a/packages/entity/src/utils/EntityPrivacyUtils.ts +++ b/packages/entity-database-adapter-knex/src/utils/EntityPrivacyUtils.ts @@ -1,16 +1,18 @@ -import { Result, asyncResult } from '@expo/results'; - -import { Entity, IEntityClass } from '../Entity'; import { + Entity, + IEntityClass, EntityEdgeDeletionAuthorizationInferenceBehavior, EntityEdgeDeletionBehavior, -} from '../EntityFieldDefinition'; -import { EntityPrivacyPolicy, EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy'; -import { EntityQueryContext } from '../EntityQueryContext'; -import { ReadonlyEntity } from '../ReadonlyEntity'; -import { ViewerContext } from '../ViewerContext'; -import { failedResults, partitionArray } from '../entityUtils'; -import { EntityNotAuthorizedError } from '../errors/EntityNotAuthorizedError'; + EntityPrivacyPolicy, + EntityPrivacyPolicyEvaluationContext, + EntityQueryContext, + ReadonlyEntity, + ViewerContext, + failedResults, + partitionArray, + EntityNotAuthorizedError, +} from '@expo/entity'; +import { Result, asyncResult } from '@expo/results'; export type EntityPrivacyEvaluationResultSuccess = { allowed: true; @@ -354,12 +356,12 @@ async function canViewerDeleteInternalAsync< entityCompanionProvider.getCompanionForEntity(inboundEdge).entityCompanionDefinition .entityConfiguration; - const loaderFactory = viewerContext.getViewerScopedEntityCompanionForClass(inboundEdge); - const loader = loaderFactory.getLoaderFactory().forLoad(queryContext, { + const entityCompanion = viewerContext.getViewerScopedEntityCompanionForClass(inboundEdge); + const loader = entityCompanion.getLoaderFactory().forLoad(queryContext, { previousValue: null, cascadingDeleteCause: newCascadingDeleteCause, }); - const knexLoader = loaderFactory.getKnexLoaderFactory().forLoad(queryContext, { + const knexLoader = entityCompanion.getKnexLoaderFactory().forLoad(queryContext, { previousValue: null, cascadingDeleteCause: newCascadingDeleteCause, }); @@ -469,7 +471,8 @@ async function canViewerDeleteInternalAsync< return { previousValue: entity, - syntheticallyUpdatedValue: entityLoader.utils.constructEntity(syntheticFields), + syntheticallyUpdatedValue: + entityLoader.constructionUtils.constructEntity(syntheticFields), }; }, ); diff --git a/packages/entity/src/utils/__tests__/EntityPrivacyUtils-test.ts b/packages/entity-database-adapter-knex/src/utils/__tests__/EntityPrivacyUtils-test.ts similarity index 94% rename from packages/entity/src/utils/__tests__/EntityPrivacyUtils-test.ts rename to packages/entity-database-adapter-knex/src/utils/__tests__/EntityPrivacyUtils-test.ts index 2f3829431..45e9d1f4c 100644 --- a/packages/entity/src/utils/__tests__/EntityPrivacyUtils-test.ts +++ b/packages/entity-database-adapter-knex/src/utils/__tests__/EntityPrivacyUtils-test.ts @@ -1,22 +1,23 @@ -import { describe, expect, it } from '@jest/globals'; -import nullthrows from 'nullthrows'; - -import { Entity } from '../../Entity'; -import { EntityCompanionDefinition } from '../../EntityCompanionProvider'; -import { EntityConfiguration } from '../../EntityConfiguration'; -import { EntityEdgeDeletionBehavior } from '../../EntityFieldDefinition'; -import { UUIDField } from '../../EntityFields'; import { + Entity, + EntityCompanionDefinition, + EntityConfiguration, + EntityEdgeDeletionBehavior, + UUIDField, EntityAuthorizationAction, EntityPrivacyPolicy, EntityPrivacyPolicyEvaluationContext, -} from '../../EntityPrivacyPolicy'; -import { EntityQueryContext } from '../../EntityQueryContext'; -import { ReadonlyEntity } from '../../ReadonlyEntity'; -import { ViewerContext } from '../../ViewerContext'; -import { AlwaysAllowPrivacyPolicyRule } from '../../rules/AlwaysAllowPrivacyPolicyRule'; -import { AlwaysDenyPrivacyPolicyRule } from '../../rules/AlwaysDenyPrivacyPolicyRule'; -import { RuleEvaluationResult } from '../../rules/PrivacyPolicyRule'; + EntityQueryContext, + ReadonlyEntity, + ViewerContext, + AlwaysAllowPrivacyPolicyRule, + AlwaysDenyPrivacyPolicyRule, + RuleEvaluationResult, +} from '@expo/entity'; +import { describe, expect, it } from '@jest/globals'; +import nullthrows from 'nullthrows'; + +import { createUnitTestPostgresEntityCompanionProvider } from '../../__tests__/fixtures/createUnitTestPostgresEntityCompanionProvider'; import { canViewerDeleteAsync, canViewerUpdateAsync, @@ -25,7 +26,6 @@ import { getCanViewerDeleteResultAsync, getCanViewerUpdateResultAsync, } from '../EntityPrivacyUtils'; -import { createUnitTestEntityCompanionProvider } from '../__testfixtures__/createUnitTestEntityCompanionProvider'; function assertEntityPrivacyEvaluationResultFailure( evaluationResult: EntityPrivacyEvaluationResult, @@ -50,7 +50,7 @@ function expectAuthorizationError( describe(canViewerUpdateAsync, () => { it('appropriately executes update privacy policy', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); + const companionProvider = createUnitTestPostgresEntityCompanionProvider(); const viewerContext = new ViewerContext(companionProvider); const testEntity = await SimpleTestDenyDeleteEntity.creator(viewerContext).createAsync(); const canViewerUpdate = await canViewerUpdateAsync(SimpleTestDenyDeleteEntity, testEntity); @@ -63,7 +63,7 @@ describe(canViewerUpdateAsync, () => { }); it('denies when policy denies', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); + const companionProvider = createUnitTestPostgresEntityCompanionProvider(); const viewerContext = new ViewerContext(companionProvider); const testEntity = await SimpleTestDenyUpdateEntity.creator(viewerContext).createAsync(); const canViewerUpdate = await canViewerUpdateAsync(SimpleTestDenyUpdateEntity, testEntity); @@ -79,7 +79,7 @@ describe(canViewerUpdateAsync, () => { }); it('rethrows non-authorization errors', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); + const companionProvider = createUnitTestPostgresEntityCompanionProvider(); const viewerContext = new ViewerContext(companionProvider); const testEntity = await SimpleTestThrowOtherErrorEntity.creator(viewerContext).createAsync(); await expect(canViewerUpdateAsync(SimpleTestThrowOtherErrorEntity, testEntity)).rejects.toThrow( @@ -93,7 +93,7 @@ describe(canViewerUpdateAsync, () => { describe(canViewerDeleteAsync, () => { it('appropriately executes update privacy policy', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); + const companionProvider = createUnitTestPostgresEntityCompanionProvider(); const viewerContext = new ViewerContext(companionProvider); const testEntity = await SimpleTestDenyUpdateEntity.creator(viewerContext).createAsync(); const canViewerDelete = await canViewerDeleteAsync(SimpleTestDenyUpdateEntity, testEntity); @@ -106,7 +106,7 @@ describe(canViewerDeleteAsync, () => { }); it('denies when policy denies', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); + const companionProvider = createUnitTestPostgresEntityCompanionProvider(); const viewerContext = new ViewerContext(companionProvider); const testEntity = await SimpleTestDenyDeleteEntity.creator(viewerContext).createAsync(); const canViewerDelete = await canViewerDeleteAsync(SimpleTestDenyDeleteEntity, testEntity); @@ -122,7 +122,7 @@ describe(canViewerDeleteAsync, () => { }); it('denies when recursive policy denies for CASCADE_DELETE', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); + const companionProvider = createUnitTestPostgresEntityCompanionProvider(); const viewerContext = new ViewerContext(companionProvider); const testEntity = await SimpleTestDenyUpdateEntity.creator(viewerContext).createAsync(); // add another entity referencing testEntity that would cascade deletion to itself when testEntity is deleted @@ -142,7 +142,7 @@ describe(canViewerDeleteAsync, () => { }); it('denies when recursive policy denies for SET_NULL', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); + const companionProvider = createUnitTestPostgresEntityCompanionProvider(); const viewerContext = new ViewerContext(companionProvider); const testEntity = await SimpleTestDenyUpdateEntity.creator(viewerContext).createAsync(); // add another entity referencing testEntity that would set null to its column when testEntity is deleted @@ -162,7 +162,7 @@ describe(canViewerDeleteAsync, () => { }); it('allows when recursive policy allows for CASCADE_DELETE and SET_NULL', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); + const companionProvider = createUnitTestPostgresEntityCompanionProvider(); const viewerContext = new ViewerContext(companionProvider); const testEntity = await SimpleTestDenyUpdateEntity.creator(viewerContext).createAsync(); // add another entity referencing testEntity that would cascade deletion to itself when testEntity is deleted @@ -184,7 +184,7 @@ describe(canViewerDeleteAsync, () => { }); it('rethrows non-authorization errors', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); + const companionProvider = createUnitTestPostgresEntityCompanionProvider(); const viewerContext = new ViewerContext(companionProvider); const testEntity = await SimpleTestThrowOtherErrorEntity.creator(viewerContext).createAsync(); await expect(canViewerDeleteAsync(SimpleTestThrowOtherErrorEntity, testEntity)).rejects.toThrow( @@ -196,7 +196,7 @@ describe(canViewerDeleteAsync, () => { }); it('returns false when edge cannot be read', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); + const companionProvider = createUnitTestPostgresEntityCompanionProvider(); const viewerContext = new ViewerContext(companionProvider); const testEntity = await SimpleTestDenyUpdateEntity.creator(viewerContext).createAsync(); const leafEntity = await LeafDenyReadEntity.creator(viewerContext) @@ -215,7 +215,7 @@ describe(canViewerDeleteAsync, () => { }); it('rethrows non-authorization edge read errors', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); + const companionProvider = createUnitTestPostgresEntityCompanionProvider(); const viewerContext = new ViewerContext(companionProvider); const testEntity = await SimpleTestDenyUpdateEntity.creator(viewerContext).createAsync(); await SimpleTestThrowOtherErrorEntity.creator(viewerContext) @@ -230,7 +230,7 @@ describe(canViewerDeleteAsync, () => { }); it('supports running within a transaction', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); + const companionProvider = createUnitTestPostgresEntityCompanionProvider(); const viewerContext = new ViewerContext(companionProvider); const canViewerDelete = await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync( 'postgres', @@ -270,7 +270,7 @@ describe(canViewerDeleteAsync, () => { }); it('evaluates privacy policy with synthetically nullified field for SET_NULL', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); + const companionProvider = createUnitTestPostgresEntityCompanionProvider(); const viewerContext = new ViewerContext(companionProvider); const testEntity = await ParentEntity.creator(viewerContext).createAsync(); @@ -289,7 +289,7 @@ describe(canViewerDeleteAsync, () => { }); it('denies deletion when privacy policy fails with synthetically nullified field for SET_NULL', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); + const companionProvider = createUnitTestPostgresEntityCompanionProvider(); const viewerContext = new ViewerContext(companionProvider); const testEntity = await ParentEntity.creator(viewerContext).createAsync(); diff --git a/packages/entity/src/utils/__tests__/canViewerDeleteAsync-edgeDeletionPermissionInferenceBehavior-test.ts b/packages/entity-database-adapter-knex/src/utils/__tests__/canViewerDeleteAsync-edgeDeletionPermissionInferenceBehavior-test.ts similarity index 90% rename from packages/entity/src/utils/__tests__/canViewerDeleteAsync-edgeDeletionPermissionInferenceBehavior-test.ts rename to packages/entity-database-adapter-knex/src/utils/__tests__/canViewerDeleteAsync-edgeDeletionPermissionInferenceBehavior-test.ts index ee65b92b0..c27aedf11 100644 --- a/packages/entity/src/utils/__tests__/canViewerDeleteAsync-edgeDeletionPermissionInferenceBehavior-test.ts +++ b/packages/entity-database-adapter-knex/src/utils/__tests__/canViewerDeleteAsync-edgeDeletionPermissionInferenceBehavior-test.ts @@ -1,25 +1,25 @@ -import { describe, expect, it, jest } from '@jest/globals'; - -import { Entity } from '../../Entity'; -import { EntityCompanionDefinition } from '../../EntityCompanionProvider'; -import { EntityConfiguration } from '../../EntityConfiguration'; import { + Entity, + EntityCompanionDefinition, + EntityConfiguration, EntityEdgeDeletionAuthorizationInferenceBehavior, EntityEdgeDeletionBehavior, -} from '../../EntityFieldDefinition'; -import { UUIDField } from '../../EntityFields'; -import { EntityPrivacyPolicy } from '../../EntityPrivacyPolicy'; -import { ReadonlyEntity } from '../../ReadonlyEntity'; -import { ViewerContext } from '../../ViewerContext'; -import { AlwaysAllowPrivacyPolicyRule } from '../../rules/AlwaysAllowPrivacyPolicyRule'; -import { AlwaysDenyPrivacyPolicyRule } from '../../rules/AlwaysDenyPrivacyPolicyRule'; + UUIDField, + EntityPrivacyPolicy, + ReadonlyEntity, + ViewerContext, + AlwaysAllowPrivacyPolicyRule, + AlwaysDenyPrivacyPolicyRule, +} from '@expo/entity'; +import { describe, expect, it, jest } from '@jest/globals'; + +import { createUnitTestPostgresEntityCompanionProvider } from '../../__tests__/fixtures/createUnitTestPostgresEntityCompanionProvider'; import { canViewerDeleteAsync } from '../EntityPrivacyUtils'; -import { createUnitTestEntityCompanionProvider } from '../__testfixtures__/createUnitTestEntityCompanionProvider'; describe(canViewerDeleteAsync, () => { describe('edgeDeletionPermissionInferenceBehavior', () => { it('optimizes when EntityEdgeDeletionPermissionInferenceBehavior.ONE_IMPLIES_ALL', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); + const companionProvider = createUnitTestPostgresEntityCompanionProvider(); const viewerContext = new ViewerContext(companionProvider); // create root @@ -61,7 +61,7 @@ describe(canViewerDeleteAsync, () => { }); it('does not optimize when undefined', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); + const companionProvider = createUnitTestPostgresEntityCompanionProvider(); const viewerContext = new ViewerContext(companionProvider); // create root diff --git a/packages/entity-example/src/adapters/InMemoryDatabaseAdapter.ts b/packages/entity-example/src/adapters/InMemoryDatabaseAdapter.ts index 9b32365ae..3a00cc6cc 100644 --- a/packages/entity-example/src/adapters/InMemoryDatabaseAdapter.ts +++ b/packages/entity-example/src/adapters/InMemoryDatabaseAdapter.ts @@ -3,10 +3,6 @@ import { EntityDatabaseAdapter, type FieldTransformerMap, type IEntityDatabaseAdapterProvider, - OrderByOrdering, - type TableFieldMultiValueEqualityCondition, - type TableFieldSingleValueEqualityCondition, - type TableQuerySelectionModifiers, getDatabaseFieldForEntityField, } from '@expo/entity'; import { v4 as uuidv4 } from 'uuid'; @@ -14,6 +10,14 @@ import { v4 as uuidv4 } from 'uuid'; const dbObjects: Readonly<{ [key: string]: any }>[] = []; export class InMemoryDatabaseAdapterProvider implements IEntityDatabaseAdapterProvider { + getExtensionsKey(): string { + return 'InMemoryDatabaseAdapterProvider'; + } + + installExtensions(): void { + // No-op + } + getDatabaseAdapter, TIDField extends keyof TFields>( entityConfiguration: EntityConfiguration, ): EntityDatabaseAdapter { @@ -51,83 +55,6 @@ class InMemoryDatabaseAdapter< return [...results]; } - private static compareByOrderBys( - orderBys: { - columnName: string; - order: OrderByOrdering; - }[], - objectA: { [key: string]: any }, - objectB: { [key: string]: any }, - ): 0 | 1 | -1 { - if (orderBys.length === 0) { - return 0; - } - - const currentOrderBy = orderBys[0]!; - const aField = objectA[currentOrderBy.columnName]; - const bField = objectB[currentOrderBy.columnName]; - switch (currentOrderBy.order) { - case OrderByOrdering.DESCENDING: - return aField > bField - ? -1 - : aField < bField - ? 1 - : this.compareByOrderBys(orderBys.slice(1), objectA, objectB); - case OrderByOrdering.ASCENDING: - return bField > aField - ? -1 - : bField < aField - ? 1 - : this.compareByOrderBys(orderBys.slice(1), objectA, objectB); - } - } - - protected async fetchManyByFieldEqualityConjunctionInternalAsync( - _queryInterface: any, - _tableName: string, - tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[], - tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[], - querySelectionModifiers: TableQuerySelectionModifiers, - ): Promise { - let filteredObjects = dbObjects; - for (const { tableField, tableValue } of tableFieldSingleValueEqualityOperands) { - filteredObjects = filteredObjects.filter((obj) => obj[tableField] === tableValue); - } - - for (const { tableField, tableValues } of tableFieldMultiValueEqualityOperands) { - filteredObjects = filteredObjects.filter((obj) => tableValues.includes(obj[tableField])); - } - - const orderBy = querySelectionModifiers.orderBy; - if (orderBy !== undefined) { - filteredObjects = filteredObjects.sort((a, b) => - InMemoryDatabaseAdapter.compareByOrderBys(orderBy, a, b), - ); - } - - const offset = querySelectionModifiers.offset; - if (offset !== undefined) { - filteredObjects = filteredObjects.slice(offset); - } - - const limit = querySelectionModifiers.limit; - if (limit !== undefined) { - filteredObjects = filteredObjects.slice(0, 0 + limit); - } - - return filteredObjects; - } - - protected fetchManyByRawWhereClauseInternalAsync( - _queryInterface: any, - _tableName: string, - _rawWhereClause: string, - _bindings: object | any[], - _querySelectionModifiers: TableQuerySelectionModifiers, - ): Promise { - throw new Error('Raw WHERE clauses not supported for InMemoryDatabaseAdapter'); - } - protected async insertInternalAsync( _queryInterface: any, _tableName: string, diff --git a/packages/entity-secondary-cache-local-memory/src/__tests__/LocalMemorySecondaryEntityCache-test.ts b/packages/entity-secondary-cache-local-memory/src/__tests__/LocalMemorySecondaryEntityCache-test.ts index f430dd3e4..0f7a72603 100644 --- a/packages/entity-secondary-cache-local-memory/src/__tests__/LocalMemorySecondaryEntityCache-test.ts +++ b/packages/entity-secondary-cache-local-memory/src/__tests__/LocalMemorySecondaryEntityCache-test.ts @@ -172,11 +172,7 @@ class TestSecondaryLocalMemoryCacheLoader extends EntitySecondaryCacheLoader< return null; } return nullthrows( - ( - await this.knexEntityLoader.loadManyByFieldEqualityConjunctionAsync([ - { fieldName: 'id', fieldValue: loadParams.id }, - ]) - )[0], + (await this.entityLoader.loadManyByFieldEqualingAsync('id', loadParams.id))[0], ) .enforceValue() .getAllFields(); @@ -198,7 +194,6 @@ describe(LocalMemorySecondaryEntityCache, () => { createTTLCache(), ), LocalMemoryTestEntity.loaderWithAuthorizationResults(viewerContext), - LocalMemoryTestEntity.knexLoaderWithAuthorizationResults(viewerContext), ); const loadParams = { id: createdEntity.getID() }; @@ -235,7 +230,6 @@ describe(LocalMemorySecondaryEntityCache, () => { createTTLCache(), ), LocalMemoryTestEntity.loaderWithAuthorizationResults(viewerContext), - LocalMemoryTestEntity.knexLoaderWithAuthorizationResults(viewerContext), ); const loadParams = { id: FAKE_ID }; diff --git a/packages/entity-secondary-cache-redis/src/__integration-tests__/RedisSecondaryEntityCache-integration-test.ts b/packages/entity-secondary-cache-redis/src/__integration-tests__/RedisSecondaryEntityCache-integration-test.ts index 32e424e16..f7f1de006 100644 --- a/packages/entity-secondary-cache-redis/src/__integration-tests__/RedisSecondaryEntityCache-integration-test.ts +++ b/packages/entity-secondary-cache-redis/src/__integration-tests__/RedisSecondaryEntityCache-integration-test.ts @@ -44,11 +44,7 @@ class TestSecondaryRedisCacheLoader extends EntitySecondaryCacheLoader< return null; } return nullthrows( - ( - await this.knexEntityLoader.loadManyByFieldEqualityConjunctionAsync([ - { fieldName: 'id', fieldValue: loadParams.id }, - ]) - )[0], + (await this.entityLoader.loadManyByFieldEqualingAsync('id', loadParams.id))[0], ) .enforceValue() .getAllFields(); @@ -98,7 +94,6 @@ describe(RedisSecondaryEntityCache, () => { (loadParams) => `test-key-${loadParams.id}`, ), RedisTestEntity.loaderWithAuthorizationResults(viewerContext), - RedisTestEntity.knexLoaderWithAuthorizationResults(viewerContext), ); const loadParams = { id: createdEntity.getID() }; @@ -138,7 +133,6 @@ describe(RedisSecondaryEntityCache, () => { (loadParams) => `test-key-${loadParams.id}`, ), RedisTestEntity.loaderWithAuthorizationResults(viewerContext), - RedisTestEntity.knexLoaderWithAuthorizationResults(viewerContext), ); const loadParams = { id: FAKE_ID }; diff --git a/packages/entity-testing-utils/src/StubDatabaseAdapter.ts b/packages/entity-testing-utils/src/StubDatabaseAdapter.ts index a10d47f58..e474512cf 100644 --- a/packages/entity-testing-utils/src/StubDatabaseAdapter.ts +++ b/packages/entity-testing-utils/src/StubDatabaseAdapter.ts @@ -3,11 +3,7 @@ import { EntityDatabaseAdapter, FieldTransformerMap, IntField, - OrderByOrdering, StringField, - TableFieldMultiValueEqualityCondition, - TableFieldSingleValueEqualityCondition, - TableQuerySelectionModifiers, computeIfAbsent, getDatabaseFieldForEntityField, mapMap, @@ -83,103 +79,6 @@ export class StubDatabaseAdapter< return [...results]; } - private static compareByOrderBys( - orderBys: { - columnName: string; - order: OrderByOrdering; - }[], - objectA: { [key: string]: any }, - objectB: { [key: string]: any }, - ): 0 | 1 | -1 { - if (orderBys.length === 0) { - return 0; - } - - const currentOrderBy = orderBys[0]!; - const aField = objectA[currentOrderBy.columnName]; - const bField = objectB[currentOrderBy.columnName]; - switch (currentOrderBy.order) { - case OrderByOrdering.DESCENDING: { - // simulate NULLS FIRST for DESC - if (aField === null && bField === null) { - return 0; - } else if (aField === null) { - return -1; - } else if (bField === null) { - return 1; - } - - return aField > bField - ? -1 - : aField < bField - ? 1 - : this.compareByOrderBys(orderBys.slice(1), objectA, objectB); - } - case OrderByOrdering.ASCENDING: { - // simulate NULLS LAST for ASC - if (aField === null && bField === null) { - return 0; - } else if (bField === null) { - return -1; - } else if (aField === null) { - return 1; - } - - return bField > aField - ? -1 - : bField < aField - ? 1 - : this.compareByOrderBys(orderBys.slice(1), objectA, objectB); - } - } - } - - protected async fetchManyByFieldEqualityConjunctionInternalAsync( - _queryInterface: any, - tableName: string, - tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[], - tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[], - querySelectionModifiers: TableQuerySelectionModifiers, - ): Promise { - let filteredObjects = this.getObjectCollectionForTable(tableName); - for (const { tableField, tableValue } of tableFieldSingleValueEqualityOperands) { - filteredObjects = filteredObjects.filter((obj) => obj[tableField] === tableValue); - } - - for (const { tableField, tableValues } of tableFieldMultiValueEqualityOperands) { - filteredObjects = filteredObjects.filter((obj) => tableValues.includes(obj[tableField])); - } - - const orderBy = querySelectionModifiers.orderBy; - if (orderBy !== undefined) { - filteredObjects = filteredObjects.sort((a, b) => - StubDatabaseAdapter.compareByOrderBys(orderBy, a, b), - ); - } - - const offset = querySelectionModifiers.offset; - if (offset !== undefined) { - filteredObjects = filteredObjects.slice(offset); - } - - const limit = querySelectionModifiers.limit; - if (limit !== undefined) { - filteredObjects = filteredObjects.slice(0, 0 + limit); - } - - return filteredObjects; - } - - protected fetchManyByRawWhereClauseInternalAsync( - _queryInterface: any, - _tableName: string, - _rawWhereClause: string, - _bindings: object | any[], - _querySelectionModifiers: TableQuerySelectionModifiers, - ): Promise { - throw new Error('Raw WHERE clauses not supported for StubDatabaseAdapter'); - } - private generateRandomID(): any { const idSchemaField = this.entityConfiguration2.schema.get(this.entityConfiguration2.idField); invariant( diff --git a/packages/entity-testing-utils/src/StubDatabaseAdapterProvider.ts b/packages/entity-testing-utils/src/StubDatabaseAdapterProvider.ts index 15535a134..48c3d4834 100644 --- a/packages/entity-testing-utils/src/StubDatabaseAdapterProvider.ts +++ b/packages/entity-testing-utils/src/StubDatabaseAdapterProvider.ts @@ -7,6 +7,14 @@ import { import { StubDatabaseAdapter } from './StubDatabaseAdapter'; export class StubDatabaseAdapterProvider implements IEntityDatabaseAdapterProvider { + getExtensionsKey(): string { + return 'StubDatabaseAdapterProvider'; + } + + installExtensions(): void { + // No-op + } + private readonly objectCollection = new Map(); getDatabaseAdapter, TIDField extends keyof TFields>( diff --git a/packages/entity-testing-utils/src/__tests__/StubDatabaseAdapter-test.ts b/packages/entity-testing-utils/src/__tests__/StubDatabaseAdapter-test.ts index fc78a2996..8daaa9038 100644 --- a/packages/entity-testing-utils/src/__tests__/StubDatabaseAdapter-test.ts +++ b/packages/entity-testing-utils/src/__tests__/StubDatabaseAdapter-test.ts @@ -2,7 +2,6 @@ import { CompositeFieldHolder, CompositeFieldValueHolder, EntityQueryContext, - OrderByOrdering, SingleFieldHolder, SingleFieldValueHolder, } from '@expo/entity'; @@ -125,230 +124,6 @@ describe(StubDatabaseAdapter, () => { }); }); - describe('fetchManyByFieldEqualityConjunctionAsync', () => { - it('supports conjuntions and query modifiers', async () => { - const queryContext = instance(mock(EntityQueryContext)); - const databaseAdapter = new StubDatabaseAdapter( - testEntityConfiguration, - StubDatabaseAdapter.convertFieldObjectsToDataStore( - testEntityConfiguration, - new Map([ - [ - testEntityConfiguration.tableName, - [ - { - customIdField: 'hello', - testIndexedField: 'h1', - intField: 3, - stringField: 'a', - dateField: new Date(), - nullableField: null, - }, - { - customIdField: 'world', - testIndexedField: 'h2', - intField: 3, - stringField: 'b', - dateField: new Date(), - nullableField: null, - }, - { - customIdField: 'world', - testIndexedField: 'h2', - intField: 3, - stringField: 'c', - dateField: new Date(), - nullableField: null, - }, - ], - ], - ]), - ), - ); - - const results = await databaseAdapter.fetchManyByFieldEqualityConjunctionAsync( - queryContext, - [ - { - fieldName: 'customIdField', - fieldValues: ['hello', 'world'], - }, - { - fieldName: 'intField', - fieldValue: 3, - }, - ], - { - limit: 2, - offset: 1, - orderBy: [ - { - fieldName: 'stringField', - order: OrderByOrdering.DESCENDING, - }, - ], - }, - ); - - expect(results).toHaveLength(2); - expect(results.map((e) => e.stringField)).toEqual(['b', 'a']); - }); - - it('supports multiple order bys', async () => { - const queryContext = instance(mock(EntityQueryContext)); - const databaseAdapter = new StubDatabaseAdapter( - testEntityConfiguration, - StubDatabaseAdapter.convertFieldObjectsToDataStore( - testEntityConfiguration, - new Map([ - [ - testEntityConfiguration.tableName, - [ - { - customIdField: 'hello', - testIndexedField: 'h1', - intField: 3, - stringField: 'a', - dateField: new Date(), - nullableField: null, - }, - { - customIdField: 'world', - testIndexedField: 'h2', - intField: 3, - stringField: 'b', - dateField: new Date(), - nullableField: null, - }, - { - customIdField: 'world', - testIndexedField: 'h2', - intField: 3, - stringField: 'c', - dateField: new Date(), - nullableField: null, - }, - ], - ], - ]), - ), - ); - - const results = await databaseAdapter.fetchManyByFieldEqualityConjunctionAsync( - queryContext, - [ - { - fieldName: 'intField', - fieldValue: 3, - }, - ], - { - orderBy: [ - { - fieldName: 'intField', - order: OrderByOrdering.DESCENDING, - }, - { - fieldName: 'stringField', - order: OrderByOrdering.DESCENDING, - }, - ], - }, - ); - - expect(results).toHaveLength(3); - expect(results.map((e) => e.stringField)).toEqual(['c', 'b', 'a']); - }); - - it('supports null field values', async () => { - const queryContext = instance(mock(EntityQueryContext)); - const databaseAdapter = new StubDatabaseAdapter( - testEntityConfiguration, - StubDatabaseAdapter.convertFieldObjectsToDataStore( - testEntityConfiguration, - new Map([ - [ - testEntityConfiguration.tableName, - [ - { - customIdField: '1', - testIndexedField: 'h1', - intField: 1, - stringField: 'a', - dateField: new Date(), - nullableField: 'a', - }, - { - customIdField: '2', - testIndexedField: 'h2', - intField: 2, - stringField: 'a', - dateField: new Date(), - nullableField: 'b', - }, - { - customIdField: '3', - testIndexedField: 'h3', - intField: 3, - stringField: 'a', - dateField: new Date(), - nullableField: null, - }, - { - customIdField: '4', - testIndexedField: 'h4', - intField: 4, - stringField: 'b', - dateField: new Date(), - nullableField: null, - }, - ], - ], - ]), - ), - ); - - const results = await databaseAdapter.fetchManyByFieldEqualityConjunctionAsync( - queryContext, - [{ fieldName: 'nullableField', fieldValue: null }], - {}, - ); - expect(results).toHaveLength(2); - expect(results[0]!.nullableField).toBeNull(); - - const results2 = await databaseAdapter.fetchManyByFieldEqualityConjunctionAsync( - queryContext, - [ - { fieldName: 'nullableField', fieldValues: ['a', null] }, - { fieldName: 'stringField', fieldValue: 'a' }, - ], - { - orderBy: [ - { - fieldName: 'nullableField', - order: OrderByOrdering.DESCENDING, - }, - ], - }, - ); - expect(results2).toHaveLength(2); - expect(results2.map((e) => e.nullableField)).toEqual([null, 'a']); - }); - }); - - describe('fetchManyByRawWhereClauseAsync', () => { - it('throws because it is unsupported', async () => { - const queryContext = instance(mock(EntityQueryContext)); - const databaseAdapter = new StubDatabaseAdapter( - testEntityConfiguration, - new Map(), - ); - await expect( - databaseAdapter.fetchManyByRawWhereClauseAsync(queryContext, '', [], {}), - ).rejects.toThrow(); - }); - }); - describe('insertAsync', () => { it('inserts a record', async () => { const queryContext = instance(mock(EntityQueryContext)); @@ -502,90 +277,6 @@ describe(StubDatabaseAdapter, () => { 'Unsupported ID type for StubDatabaseAdapter: DateField', ); }); - - describe('compareByOrderBys', () => { - describe('comparison', () => { - it.each([ - // nulls compare with 0 - [OrderByOrdering.DESCENDING, null, 0, -1], - [OrderByOrdering.ASCENDING, null, 0, 1], - [OrderByOrdering.DESCENDING, 0, null, 1], - [OrderByOrdering.ASCENDING, 0, null, -1], - - // nulls compare with nulls - [OrderByOrdering.DESCENDING, null, null, 0], - [OrderByOrdering.ASCENDING, null, null, 0], - - // nulls compare with -1 - [OrderByOrdering.DESCENDING, null, -1, -1], - [OrderByOrdering.ASCENDING, null, -1, 1], - [OrderByOrdering.DESCENDING, -1, null, 1], - [OrderByOrdering.ASCENDING, -1, null, -1], - - // basic compares - [OrderByOrdering.ASCENDING, 'a', 'b', -1], - [OrderByOrdering.ASCENDING, 'b', 'a', 1], - [OrderByOrdering.DESCENDING, 'a', 'b', 1], - [OrderByOrdering.DESCENDING, 'b', 'a', -1], - ])('case (%p; %p; %p)', (order, v1, v2, expectedResult) => { - expect( - StubDatabaseAdapter['compareByOrderBys']( - [ - { - columnName: 'hello', - order, - }, - ], - { - hello: v1, - }, - { - hello: v2, - }, - ), - ).toEqual(expectedResult); - }); - - it('works for empty', () => { - expect( - StubDatabaseAdapter['compareByOrderBys']( - [], - { - hello: 'test', - }, - { - hello: 'blah', - }, - ), - ).toEqual(0); - }); - }); - - describe('recursing', () => { - expect( - StubDatabaseAdapter['compareByOrderBys']( - [ - { - columnName: 'hello', - order: OrderByOrdering.ASCENDING, - }, - { - columnName: 'world', - order: OrderByOrdering.ASCENDING, - }, - ], - { - hello: 'a', - world: 1, - }, - { - hello: 'a', - world: 2, - }, - ), - ).toEqual(-1); - }); - }); }); /** diff --git a/packages/entity/src/AuthorizationResultBasedEntityLoader.ts b/packages/entity/src/AuthorizationResultBasedEntityLoader.ts index b10f8ca0a..f2cce2d46 100644 --- a/packages/entity/src/AuthorizationResultBasedEntityLoader.ts +++ b/packages/entity/src/AuthorizationResultBasedEntityLoader.ts @@ -8,7 +8,8 @@ import { EntityCompositeFieldValue, EntityConfiguration, } from './EntityConfiguration'; -import { EntityLoaderUtils } from './EntityLoaderUtils'; +import { EntityConstructionUtils } from './EntityConstructionUtils'; +import { EntityInvalidationUtils } from './EntityInvalidationUtils'; import { EntityPrivacyPolicy } from './EntityPrivacyPolicy'; import { EntityQueryContext } from './EntityQueryContext'; import { ReadonlyEntity } from './ReadonlyEntity'; @@ -55,7 +56,15 @@ export class AuthorizationResultBasedEntityLoader< >, private readonly dataManager: EntityDataManager, protected readonly metricsAdapter: IEntityMetricsAdapter, - public readonly utils: EntityLoaderUtils< + public readonly invalidationUtils: EntityInvalidationUtils< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >, + public readonly constructionUtils: EntityConstructionUtils< TFields, TIDField, TViewerContext, @@ -89,7 +98,9 @@ export class AuthorizationResultBasedEntityLoader< loadValuesToFieldObjects, (loadValue) => loadValue.fieldValue, ); - return await this.utils.constructAndAuthorizeEntitiesAsync(fieldValuesToFieldObjects); + return await this.constructionUtils.constructAndAuthorizeEntitiesAsync( + fieldValuesToFieldObjects, + ); } /** @@ -265,7 +276,7 @@ export class AuthorizationResultBasedEntityLoader< loadKey: SingleFieldHolder; loadValues: readonly SingleFieldValueHolder[]; } { - this.utils.validateFieldAndValues(fieldName, fieldValues); + this.constructionUtils.validateFieldAndValues(fieldName, fieldValues); return { loadKey: new SingleFieldHolder(fieldName), @@ -308,7 +319,7 @@ export class AuthorizationResultBasedEntityLoader< ); for (const field of compositeField) { const fieldValue = compositeFieldValueHolder.compositeFieldValue[field]; - this.utils.validateFieldAndValues(field, [fieldValue]); + this.constructionUtils.validateFieldAndValues(field, [fieldValue]); } } @@ -328,7 +339,7 @@ export class AuthorizationResultBasedEntityLoader< Array.from(map.entries()).map(async ([compositeFieldValueHolder, fieldObjects]) => { return [ compositeFieldValueHolder, - await this.utils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects), + await this.constructionUtils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects), ]; }), ), diff --git a/packages/entity/src/AuthorizationResultBasedEntityMutator.ts b/packages/entity/src/AuthorizationResultBasedEntityMutator.ts index c00f3c950..9190e056d 100644 --- a/packages/entity/src/AuthorizationResultBasedEntityMutator.ts +++ b/packages/entity/src/AuthorizationResultBasedEntityMutator.ts @@ -334,7 +334,7 @@ export class AuthorizationResultBasedCreateMutator< cascadingDeleteCause: null, }); - const temporaryEntityForPrivacyCheck = entityLoader.utils.constructEntity({ + const temporaryEntityForPrivacyCheck = entityLoader.constructionUtils.constructEntity({ [this.entityConfiguration.idField]: '00000000-0000-0000-0000-000000000000', // zero UUID ...this.fieldsForEntity, } as unknown as TFields); @@ -376,13 +376,14 @@ export class AuthorizationResultBasedCreateMutator< // Invalidate all caches for the new entity so that any previously-negatively-cached loads // are removed from the caches. queryContext.appendPostCommitInvalidationCallback(async () => { - entityLoader.utils.invalidateFieldsForTransaction(queryContext, insertResult); - await entityLoader.utils.invalidateFieldsAsync(insertResult); + entityLoader.invalidationUtils.invalidateFieldsForTransaction(queryContext, insertResult); + await entityLoader.invalidationUtils.invalidateFieldsAsync(insertResult); }); - entityLoader.utils.invalidateFieldsForTransaction(queryContext, insertResult); + entityLoader.invalidationUtils.invalidateFieldsForTransaction(queryContext, insertResult); - const unauthorizedEntityAfterInsert = entityLoader.utils.constructEntity(insertResult); + const unauthorizedEntityAfterInsert = + entityLoader.constructionUtils.constructEntity(insertResult); const newEntity = await enforceAsyncResult( entityLoader.loadByIDAsync(unauthorizedEntityAfterInsert.getID()), ); @@ -542,7 +543,9 @@ export class AuthorizationResultBasedUpdateMutator< cascadingDeleteCause: this.cascadingDeleteCause, }); - const entityAboutToBeUpdated = entityLoader.utils.constructEntity(this.fieldsForEntity); + const entityAboutToBeUpdated = entityLoader.constructionUtils.constructEntity( + this.fieldsForEntity, + ); const authorizeUpdateResult = await asyncResult( this.privacyPolicy.authorizeUpdateAsync( this.viewerContext, @@ -606,22 +609,30 @@ export class AuthorizationResultBasedUpdateMutator< // version of the entity. queryContext.appendPostCommitInvalidationCallback(async () => { - entityLoader.utils.invalidateFieldsForTransaction( + entityLoader.invalidationUtils.invalidateFieldsForTransaction( queryContext, this.originalEntity.getAllDatabaseFields(), ); - entityLoader.utils.invalidateFieldsForTransaction(queryContext, this.fieldsForEntity); + entityLoader.invalidationUtils.invalidateFieldsForTransaction( + queryContext, + this.fieldsForEntity, + ); await Promise.all([ - entityLoader.utils.invalidateFieldsAsync(this.originalEntity.getAllDatabaseFields()), - entityLoader.utils.invalidateFieldsAsync(this.fieldsForEntity), + entityLoader.invalidationUtils.invalidateFieldsAsync( + this.originalEntity.getAllDatabaseFields(), + ), + entityLoader.invalidationUtils.invalidateFieldsAsync(this.fieldsForEntity), ]); }); - entityLoader.utils.invalidateFieldsForTransaction( + entityLoader.invalidationUtils.invalidateFieldsForTransaction( queryContext, this.originalEntity.getAllDatabaseFields(), ); - entityLoader.utils.invalidateFieldsForTransaction(queryContext, this.fieldsForEntity); + entityLoader.invalidationUtils.invalidateFieldsForTransaction( + queryContext, + this.fieldsForEntity, + ); const updatedEntity = await enforceAsyncResult( entityLoader.loadByIDAsync(entityAboutToBeUpdated.getID()), @@ -847,13 +858,15 @@ export class AuthorizationResultBasedDeleteMutator< // Invalidate all caches for the entity so that any previously-cached loads // are removed from the caches. queryContext.appendPostCommitInvalidationCallback(async () => { - entityLoader.utils.invalidateFieldsForTransaction( + entityLoader.invalidationUtils.invalidateFieldsForTransaction( queryContext, this.entity.getAllDatabaseFields(), ); - await entityLoader.utils.invalidateFieldsAsync(this.entity.getAllDatabaseFields()); + await entityLoader.invalidationUtils.invalidateFieldsAsync( + this.entity.getAllDatabaseFields(), + ); }); - entityLoader.utils.invalidateFieldsForTransaction( + entityLoader.invalidationUtils.invalidateFieldsForTransaction( queryContext, this.entity.getAllDatabaseFields(), ); diff --git a/packages/entity/src/EntityCompanion.ts b/packages/entity/src/EntityCompanion.ts index 97f801732..26b5259b3 100644 --- a/packages/entity/src/EntityCompanion.ts +++ b/packages/entity/src/EntityCompanion.ts @@ -3,7 +3,6 @@ import { EntityLoaderFactory } from './EntityLoaderFactory'; import { EntityMutatorFactory } from './EntityMutatorFactory'; import { EntityPrivacyPolicy } from './EntityPrivacyPolicy'; import { EntityQueryContextProvider } from './EntityQueryContextProvider'; -import { KnexEntityLoaderFactory } from './KnexEntityLoaderFactory'; import { ReadonlyEntity } from './ReadonlyEntity'; import { ViewerContext } from './ViewerContext'; import { EntityTableDataCoordinator } from './internal/EntityTableDataCoordinator'; @@ -41,14 +40,6 @@ export class EntityCompanion< TPrivacyPolicy, TSelectedFields >; - private readonly knexEntityLoaderFactory: KnexEntityLoaderFactory< - TFields, - TIDField, - TViewerContext, - TEntity, - TPrivacyPolicy, - TSelectedFields - >; private readonly entityMutatorFactory: EntityMutatorFactory< TFields, TIDField, @@ -68,8 +59,8 @@ export class EntityCompanion< TPrivacyPolicy, TSelectedFields >, - private readonly tableDataCoordinator: EntityTableDataCoordinator, - private readonly metricsAdapter: IEntityMetricsAdapter, + public readonly tableDataCoordinator: EntityTableDataCoordinator, + public readonly metricsAdapter: IEntityMetricsAdapter, ) { this.privacyPolicy = new entityCompanionDefinition.privacyPolicyClass(); this.entityLoaderFactory = new EntityLoaderFactory< @@ -80,14 +71,6 @@ export class EntityCompanion< TPrivacyPolicy, TSelectedFields >(this, tableDataCoordinator.dataManager, metricsAdapter); - this.knexEntityLoaderFactory = new KnexEntityLoaderFactory< - TFields, - TIDField, - TViewerContext, - TEntity, - TPrivacyPolicy, - TSelectedFields - >(this, tableDataCoordinator.dataManager, tableDataCoordinator.knexDataManager, metricsAdapter); this.entityMutatorFactory = new EntityMutatorFactory( entityCompanionProvider, tableDataCoordinator.entityConfiguration, @@ -115,17 +98,6 @@ export class EntityCompanion< return this.entityLoaderFactory; } - getKnexLoaderFactory(): KnexEntityLoaderFactory< - TFields, - TIDField, - TViewerContext, - TEntity, - TPrivacyPolicy, - TSelectedFields - > { - return this.knexEntityLoaderFactory; - } - getMutatorFactory(): EntityMutatorFactory< TFields, TIDField, diff --git a/packages/entity/src/EntityCompanionProvider.ts b/packages/entity/src/EntityCompanionProvider.ts index 2935285a2..6c0d1e977 100644 --- a/packages/entity/src/EntityCompanionProvider.ts +++ b/packages/entity/src/EntityCompanionProvider.ts @@ -133,6 +133,7 @@ export class EntityCompanionProvider { new Map(); private readonly tableDataCoordinatorMap: Map> = new Map(); + private static readonly installedExtensions = new Set(); /** * Instantiate an Entity framework. @@ -158,7 +159,21 @@ export class EntityCompanionProvider { any, any > = {}, - ) {} + ) { + // Install any extensions required by the database adapter flavors + for (const flavorDefinition of databaseAdapterFlavors.values()) { + if ( + !EntityCompanionProvider.installedExtensions.has( + flavorDefinition.adapterProvider.getExtensionsKey(), + ) + ) { + flavorDefinition.adapterProvider.installExtensions(); + EntityCompanionProvider.installedExtensions.add( + flavorDefinition.adapterProvider.getExtensionsKey(), + ); + } + } + } /** * Get the entity companion for specified entity. If not already computed and cached, the entity diff --git a/packages/entity/src/EntityLoaderUtils.ts b/packages/entity/src/EntityConstructionUtils.ts similarity index 61% rename from packages/entity/src/EntityLoaderUtils.ts rename to packages/entity/src/EntityConstructionUtils.ts index 61ebb6255..89b9af907 100644 --- a/packages/entity/src/EntityLoaderUtils.ts +++ b/packages/entity/src/EntityConstructionUtils.ts @@ -5,22 +5,19 @@ import nullthrows from 'nullthrows'; import { IEntityClass } from './Entity'; import { EntityConfiguration } from './EntityConfiguration'; import { EntityPrivacyPolicy, EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy'; -import { EntityQueryContext, EntityTransactionalQueryContext } from './EntityQueryContext'; +import { EntityQueryContext } from './EntityQueryContext'; import { ReadonlyEntity } from './ReadonlyEntity'; import { ViewerContext } from './ViewerContext'; import { pick } from './entityUtils'; import { EntityInvalidFieldValueError } from './errors/EntityInvalidFieldValueError'; -import { EntityDataManager } from './internal/EntityDataManager'; -import { LoadPair } from './internal/EntityLoadInterfaces'; -import { SingleFieldHolder, SingleFieldValueHolder } from './internal/SingleFieldHolder'; import { IEntityMetricsAdapter } from './metrics/IEntityMetricsAdapter'; import { mapMapAsync } from './utils/collections/maps'; /** - * Entity loader utilities for things like invalidation, entity construction, and authorization. + * Common entity loader utilities for entity construction and authorization. * Methods are exposed publicly since in rare cases they may need to be called manually. */ -export class EntityLoaderUtils< +export class EntityConstructionUtils< TFields extends Record, TIDField extends keyof NonNullable>, TViewerContext extends ViewerContext, @@ -55,82 +52,9 @@ export class EntityLoaderUtils< >, private readonly entitySelectedFields: TSelectedFields[] | undefined, private readonly privacyPolicy: TPrivacyPolicy, - private readonly dataManager: EntityDataManager, protected readonly metricsAdapter: IEntityMetricsAdapter, ) {} - private getKeyValuePairsFromObjectFields( - objectFields: Readonly, - ): readonly LoadPair[] { - const keys = Object.keys(objectFields) as (keyof TFields)[]; - const singleFieldKeyValues: LoadPair[] = []; - for (const fieldName of keys) { - const value = objectFields[fieldName]; - if (value !== undefined && value !== null) { - singleFieldKeyValues.push([ - new SingleFieldHolder(fieldName), - new SingleFieldValueHolder(value), - ]); - } - } - - const compositeFieldKeyValues: LoadPair[] = []; - for (const compositeFieldHolder of this.entityConfiguration.compositeFieldInfo.getAllCompositeFieldHolders()) { - const compositeFieldValueHolder = - compositeFieldHolder.extractCompositeFieldValueHolderFromObjectFields(objectFields); - if (compositeFieldValueHolder) { - compositeFieldKeyValues.push([compositeFieldHolder, compositeFieldValueHolder]); - } - } - - return [...singleFieldKeyValues, ...compositeFieldKeyValues]; - } - - /** - * Invalidate all caches and local dataloaders for an entity's fields. Exposed primarily for internal use by EntityMutator. - * @param objectFields - entity data object to be invalidated - */ - public async invalidateFieldsAsync(objectFields: Readonly): Promise { - await this.dataManager.invalidateKeyValuePairsAsync( - this.getKeyValuePairsFromObjectFields(objectFields), - ); - } - - /** - * Invalidate all local dataloaders specific to a transaction for an entity's fields. Exposed primarily for internal use by EntityMutator. - * @param objectFields - entity data object to be invalidated - */ - public invalidateFieldsForTransaction( - queryContext: EntityTransactionalQueryContext, - objectFields: Readonly, - ): void { - this.dataManager.invalidateKeyValuePairsForTransaction( - queryContext, - this.getKeyValuePairsFromObjectFields(objectFields), - ); - } - - /** - * Invalidate all caches and local dataloaders for an entity. One potential use case would be to keep the entity - * framework in sync with changes made to data outside of the framework. - * @param entity - entity to be invalidated - */ - public async invalidateEntityAsync(entity: TEntity): Promise { - await this.invalidateFieldsAsync(entity.getAllDatabaseFields()); - } - - /** - * Invalidate all local dataloaders specific to a transaction for an entity. One potential use case would be to keep the entity - * framework in sync with changes made to data outside of the framework. - * @param entity - entity to be invalidated - */ - public invalidateEntityForTransaction( - queryContext: EntityTransactionalQueryContext, - entity: TEntity, - ): void { - this.invalidateFieldsForTransaction(queryContext, entity.getAllDatabaseFields()); - } - /** * Construct an entity from a fields object (applying field selection if applicable), * checking that the ID field is specified. diff --git a/packages/entity/src/EntityDatabaseAdapter.ts b/packages/entity/src/EntityDatabaseAdapter.ts index ad181613d..75cc1f25f 100644 --- a/packages/entity/src/EntityDatabaseAdapter.ts +++ b/packages/entity/src/EntityDatabaseAdapter.ts @@ -17,127 +17,6 @@ import { } from './internal/EntityFieldTransformationUtils'; import { IEntityLoadKey, IEntityLoadValue } from './internal/EntityLoadInterfaces'; -/** - * 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; -} - -export interface TableFieldMultiValueEqualityCondition { - tableField: string; - tableValues: readonly any[]; -} - -/** - * Ordering options for `orderBy` clauses. - */ -export enum OrderByOrdering { - /** - * Ascending order (lowest to highest). - * Ascending order puts smaller values first, where "smaller" is defined in terms of the %3C operator. - */ - ASCENDING = 'asc', - - /** - * Descending order (highest to lowest). - * Descending order puts larger values first, where "larger" is defined in terms of the %3E operator. - */ - DESCENDING = 'desc', -} - -/** - * SQL modifiers that only affect the selection but not the projection. - */ -export interface QuerySelectionModifiers> { - /** - * Order the entities by specified columns and orders. - */ - orderBy?: { - /** - * The field name to order by. - */ - fieldName: keyof TFields; - - /** - * The OrderByOrdering to order by. - */ - order: OrderByOrdering; - }[]; - - /** - * Skip the specified number of entities queried before returning. - */ - offset?: number; - - /** - * Limit the number of entities returned. - */ - limit?: number; -} - -export interface QuerySelectionModifiersWithOrderByRaw< - TFields extends Record, -> extends QuerySelectionModifiers { - /** - * Order the entities by a raw SQL `ORDER BY` clause. - */ - orderByRaw?: string; -} - -export interface TableQuerySelectionModifiers { - orderBy: - | { - columnName: string; - order: OrderByOrdering; - }[] - | undefined; - offset: number | undefined; - limit: number | undefined; -} - -export interface TableQuerySelectionModifiersWithOrderByRaw extends TableQuerySelectionModifiers { - orderByRaw: string | undefined; -} - /** * A database adapter is an interface by which entity objects can be * fetched, inserted, updated, and deleted from a database. This base class @@ -148,9 +27,9 @@ export abstract class EntityDatabaseAdapter< TFields extends Record, TIDField extends keyof TFields, > { - private readonly fieldTransformerMap: FieldTransformerMap; + protected readonly fieldTransformerMap: FieldTransformerMap; - constructor(private readonly entityConfiguration: EntityConfiguration) { + constructor(protected readonly entityConfiguration: EntityConfiguration) { this.fieldTransformerMap = this.getFieldTransformerMap(); } @@ -221,93 +100,6 @@ export abstract class EntityDatabaseAdapter< tableTuples: (readonly any[])[], ): Promise; - /** - * Fetch many objects matching the conjunction of where clauses constructed from - * specified field equality operands. - * - * @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 - * @returns array of objects matching the query - */ - async fetchManyByFieldEqualityConjunctionAsync( - queryContext: EntityQueryContext, - fieldEqualityOperands: FieldEqualityCondition[], - querySelectionModifiers: QuerySelectionModifiers, - ): Promise[]> { - const tableFieldSingleValueOperands: TableFieldSingleValueEqualityCondition[] = []; - const tableFieldMultipleValueOperands: TableFieldMultiValueEqualityCondition[] = []; - for (const operand of fieldEqualityOperands) { - if (isSingleValueFieldEqualityCondition(operand)) { - tableFieldSingleValueOperands.push({ - tableField: getDatabaseFieldForEntityField(this.entityConfiguration, operand.fieldName), - tableValue: operand.fieldValue, - }); - } else { - tableFieldMultipleValueOperands.push({ - tableField: getDatabaseFieldForEntityField(this.entityConfiguration, operand.fieldName), - tableValues: operand.fieldValues, - }); - } - } - - const results = await this.fetchManyByFieldEqualityConjunctionInternalAsync( - queryContext.getQueryInterface(), - this.entityConfiguration.tableName, - tableFieldSingleValueOperands, - tableFieldMultipleValueOperands, - this.convertToTableQueryModifiers(querySelectionModifiers), - ); - - return results.map((result) => - transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result), - ); - } - - protected abstract fetchManyByFieldEqualityConjunctionInternalAsync( - queryInterface: any, - tableName: string, - tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[], - tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[], - querySelectionModifiers: TableQuerySelectionModifiers, - ): Promise; - - /** - * Fetch many objects matching the raw WHERE clause. - * - * @param queryContext - query context with which to perform the fetch - * @param rawWhereClause - parameterized SQL WHERE clause with positional binding placeholders or named binding placeholders - * @param bindings - array of positional bindings or object of named bindings - * @param querySelectionModifiers - limit, offset, and orderBy for the query - * @returns array of objects matching the query - */ - async fetchManyByRawWhereClauseAsync( - queryContext: EntityQueryContext, - rawWhereClause: string, - bindings: any[] | object, - querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw, - ): Promise[]> { - const results = await this.fetchManyByRawWhereClauseInternalAsync( - queryContext.getQueryInterface(), - this.entityConfiguration.tableName, - rawWhereClause, - bindings, - this.convertToTableQueryModifiersWithOrderByRaw(querySelectionModifiers), - ); - - return results.map((result) => - transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result), - ); - } - - protected abstract fetchManyByRawWhereClauseInternalAsync( - queryInterface: any, - tableName: string, - rawWhereClause: string, - bindings: any[] | object, - querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw, - ): Promise; - /** * Insert an object. * @@ -446,33 +238,4 @@ export abstract class EntityDatabaseAdapter< tableIdField: string, id: any, ): Promise; - - private convertToTableQueryModifiersWithOrderByRaw( - querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw, - ): TableQuerySelectionModifiersWithOrderByRaw { - return { - ...this.convertToTableQueryModifiers(querySelectionModifiers), - orderByRaw: querySelectionModifiers.orderByRaw, - }; - } - - private convertToTableQueryModifiers( - querySelectionModifiers: QuerySelectionModifiers, - ): TableQuerySelectionModifiers { - const orderBy = querySelectionModifiers.orderBy; - return { - orderBy: - orderBy !== undefined - ? orderBy.map((orderBySpecification) => ({ - columnName: getDatabaseFieldForEntityField( - this.entityConfiguration, - orderBySpecification.fieldName, - ), - order: orderBySpecification.order, - })) - : undefined, - offset: querySelectionModifiers.offset, - limit: querySelectionModifiers.limit, - }; - } } diff --git a/packages/entity/src/EntityInvalidationUtils.ts b/packages/entity/src/EntityInvalidationUtils.ts new file mode 100644 index 000000000..6705b1a88 --- /dev/null +++ b/packages/entity/src/EntityInvalidationUtils.ts @@ -0,0 +1,115 @@ +import { IEntityClass } from './Entity'; +import { EntityConfiguration } from './EntityConfiguration'; +import { EntityPrivacyPolicy } from './EntityPrivacyPolicy'; +import { EntityTransactionalQueryContext } from './EntityQueryContext'; +import { ReadonlyEntity } from './ReadonlyEntity'; +import { ViewerContext } from './ViewerContext'; +import { EntityDataManager } from './internal/EntityDataManager'; +import { LoadPair } from './internal/EntityLoadInterfaces'; +import { SingleFieldHolder, SingleFieldValueHolder } from './internal/SingleFieldHolder'; +import { IEntityMetricsAdapter } from './metrics/IEntityMetricsAdapter'; + +/** + * Entity invalidation utilities. + * Methods are exposed publicly since in rare cases they may need to be called manually. + */ +export class EntityInvalidationUtils< + TFields extends Record, + TIDField extends keyof NonNullable>, + TViewerContext extends ViewerContext, + TEntity extends ReadonlyEntity, + TPrivacyPolicy extends EntityPrivacyPolicy< + TFields, + TIDField, + TViewerContext, + TEntity, + TSelectedFields + >, + TSelectedFields extends keyof TFields, +> { + constructor( + private readonly entityConfiguration: EntityConfiguration, + _entityClass: IEntityClass< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >, + private readonly dataManager: EntityDataManager, + protected readonly metricsAdapter: IEntityMetricsAdapter, + ) {} + + private getKeyValuePairsFromObjectFields( + objectFields: Readonly, + ): readonly LoadPair[] { + const keys = Object.keys(objectFields) as (keyof TFields)[]; + const singleFieldKeyValues: LoadPair[] = []; + for (const fieldName of keys) { + const value = objectFields[fieldName]; + if (value !== undefined && value !== null) { + singleFieldKeyValues.push([ + new SingleFieldHolder(fieldName), + new SingleFieldValueHolder(value), + ]); + } + } + + const compositeFieldKeyValues: LoadPair[] = []; + for (const compositeFieldHolder of this.entityConfiguration.compositeFieldInfo.getAllCompositeFieldHolders()) { + const compositeFieldValueHolder = + compositeFieldHolder.extractCompositeFieldValueHolderFromObjectFields(objectFields); + if (compositeFieldValueHolder) { + compositeFieldKeyValues.push([compositeFieldHolder, compositeFieldValueHolder]); + } + } + + return [...singleFieldKeyValues, ...compositeFieldKeyValues]; + } + + /** + * Invalidate all caches and local dataloaders for an entity's fields. Exposed primarily for internal use by EntityMutator. + * @param objectFields - entity data object to be invalidated + */ + public async invalidateFieldsAsync(objectFields: Readonly): Promise { + await this.dataManager.invalidateKeyValuePairsAsync( + this.getKeyValuePairsFromObjectFields(objectFields), + ); + } + + /** + * Invalidate all local dataloaders specific to a transaction for an entity's fields. Exposed primarily for internal use by EntityMutator. + * @param objectFields - entity data object to be invalidated + */ + public invalidateFieldsForTransaction( + queryContext: EntityTransactionalQueryContext, + objectFields: Readonly, + ): void { + this.dataManager.invalidateKeyValuePairsForTransaction( + queryContext, + this.getKeyValuePairsFromObjectFields(objectFields), + ); + } + + /** + * Invalidate all caches and local dataloaders for an entity. One potential use case would be to keep the entity + * framework in sync with changes made to data outside of the framework. + * @param entity - entity to be invalidated + */ + public async invalidateEntityAsync(entity: TEntity): Promise { + await this.invalidateFieldsAsync(entity.getAllDatabaseFields()); + } + + /** + * Invalidate all local dataloaders specific to a transaction for an entity. One potential use case would be to keep the entity + * framework in sync with changes made to data outside of the framework. + * @param entity - entity to be invalidated + */ + public invalidateEntityForTransaction( + queryContext: EntityTransactionalQueryContext, + entity: TEntity, + ): void { + this.invalidateFieldsForTransaction(queryContext, entity.getAllDatabaseFields()); + } +} diff --git a/packages/entity/src/EntityLoader.ts b/packages/entity/src/EntityLoader.ts index 176ea5255..51c32ccbc 100644 --- a/packages/entity/src/EntityLoader.ts +++ b/packages/entity/src/EntityLoader.ts @@ -1,7 +1,8 @@ import { AuthorizationResultBasedEntityLoader } from './AuthorizationResultBasedEntityLoader'; import { EnforcingEntityLoader } from './EnforcingEntityLoader'; import { IEntityClass } from './Entity'; -import { EntityLoaderUtils } from './EntityLoaderUtils'; +import { EntityConstructionUtils } from './EntityConstructionUtils'; +import { EntityInvalidationUtils } from './EntityInvalidationUtils'; import { EntityPrivacyPolicy } from './EntityPrivacyPolicy'; import { EntityQueryContext } from './EntityQueryContext'; import { ReadonlyEntity } from './ReadonlyEntity'; @@ -75,10 +76,10 @@ export class EntityLoader< } /** - * Entity loader utilities for things like cache invalidation, entity construction, and authorization. + * Entity cache invalidation utilities. * Calling into these should only be necessary in rare cases. */ - public utils(): EntityLoaderUtils< + public invalidationUtils(): EntityInvalidationUtils< TFields, TIDField, TViewerContext, @@ -86,6 +87,21 @@ export class EntityLoader< TPrivacyPolicy, TSelectedFields > { - return this.withAuthorizationResults().utils; + return this.withAuthorizationResults().invalidationUtils; + } + + /** + * Entity construction and validation utilities. + * Calling into these should only be necessary in rare cases. + */ + public constructionUtils(): EntityConstructionUtils< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + > { + return this.withAuthorizationResults().constructionUtils; } } diff --git a/packages/entity/src/EntityLoaderFactory.ts b/packages/entity/src/EntityLoaderFactory.ts index 1aa9c54e8..c6f931868 100644 --- a/packages/entity/src/EntityLoaderFactory.ts +++ b/packages/entity/src/EntityLoaderFactory.ts @@ -1,6 +1,7 @@ import { AuthorizationResultBasedEntityLoader } from './AuthorizationResultBasedEntityLoader'; import { EntityCompanion } from './EntityCompanion'; -import { EntityLoaderUtils } from './EntityLoaderUtils'; +import { EntityConstructionUtils } from './EntityConstructionUtils'; +import { EntityInvalidationUtils } from './EntityInvalidationUtils'; import { EntityPrivacyPolicy, EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy'; import { EntityQueryContext } from './EntityQueryContext'; import { ReadonlyEntity } from './ReadonlyEntity'; @@ -61,7 +62,13 @@ export class EntityLoaderFactory< TPrivacyPolicy, TSelectedFields > { - const utils = new EntityLoaderUtils( + const invalidationUtils = new EntityInvalidationUtils( + this.entityCompanion.entityCompanionDefinition.entityConfiguration, + this.entityCompanion.entityCompanionDefinition.entityClass, + this.dataManager, + this.metricsAdapter, + ); + const constructionUtils = new EntityConstructionUtils( viewerContext, queryContext, privacyPolicyEvaluationContext, @@ -69,7 +76,6 @@ export class EntityLoaderFactory< this.entityCompanion.entityCompanionDefinition.entityClass, this.entityCompanion.entityCompanionDefinition.entitySelectedFields, this.entityCompanion.privacyPolicy, - this.dataManager, this.metricsAdapter, ); @@ -79,7 +85,8 @@ export class EntityLoaderFactory< this.entityCompanion.entityCompanionDefinition.entityClass, this.dataManager, this.metricsAdapter, - utils, + invalidationUtils, + constructionUtils, ); } } diff --git a/packages/entity/src/EntitySecondaryCacheLoader.ts b/packages/entity/src/EntitySecondaryCacheLoader.ts index 24ddb1957..f8bb30b5d 100644 --- a/packages/entity/src/EntitySecondaryCacheLoader.ts +++ b/packages/entity/src/EntitySecondaryCacheLoader.ts @@ -1,7 +1,6 @@ import { Result } from '@expo/results'; import { AuthorizationResultBasedEntityLoader } from './AuthorizationResultBasedEntityLoader'; -import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader'; import { EntityPrivacyPolicy } from './EntityPrivacyPolicy'; import { ReadonlyEntity } from './ReadonlyEntity'; import { ViewerContext } from './ViewerContext'; @@ -69,14 +68,6 @@ export abstract class EntitySecondaryCacheLoader< TPrivacyPolicy, TSelectedFields >, - protected readonly knexEntityLoader: AuthorizationResultBasedKnexEntityLoader< - TFields, - TIDField, - TViewerContext, - TEntity, - TPrivacyPolicy, - TSelectedFields - >, ) {} /** @@ -93,9 +84,10 @@ export abstract class EntitySecondaryCacheLoader< ); // convert value to and from array to reuse complex code - const entitiesMap = await this.entityLoader.utils.constructAndAuthorizeEntitiesAsync( - mapMap(loadParamsToFieldObjects, (fieldObject) => (fieldObject ? [fieldObject] : [])), - ); + const entitiesMap = + await this.entityLoader.constructionUtils.constructAndAuthorizeEntitiesAsync( + mapMap(loadParamsToFieldObjects, (fieldObject) => (fieldObject ? [fieldObject] : [])), + ); return mapMap(entitiesMap, (fieldObjects) => fieldObjects[0] ?? null); } diff --git a/packages/entity/src/IEntityDatabaseAdapterProvider.ts b/packages/entity/src/IEntityDatabaseAdapterProvider.ts index 04c7e0aff..8bf5a0c73 100644 --- a/packages/entity/src/IEntityDatabaseAdapterProvider.ts +++ b/packages/entity/src/IEntityDatabaseAdapterProvider.ts @@ -8,6 +8,16 @@ import { EntityDatabaseAdapter } from './EntityDatabaseAdapter'; * Allows for passing global configuration to databse adapters, making testing easier. */ export interface IEntityDatabaseAdapterProvider { + /** + * A unique key for this type of adapter provider, used to avoid installing extensions multiple times. + */ + getExtensionsKey(): string; + + /** + * Install any necessary extensions to the Entity system. + */ + installExtensions(): void; + /** * Vend a database adapter. */ diff --git a/packages/entity/src/ReadonlyEntity.ts b/packages/entity/src/ReadonlyEntity.ts index 8c7cfb5e6..703575163 100644 --- a/packages/entity/src/ReadonlyEntity.ts +++ b/packages/entity/src/ReadonlyEntity.ts @@ -2,17 +2,15 @@ import invariant from 'invariant'; import { AuthorizationResultBasedEntityAssociationLoader } from './AuthorizationResultBasedEntityAssociationLoader'; import { AuthorizationResultBasedEntityLoader } from './AuthorizationResultBasedEntityLoader'; -import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader'; import { EnforcingEntityAssociationLoader } from './EnforcingEntityAssociationLoader'; import { EnforcingEntityLoader } from './EnforcingEntityLoader'; -import { EnforcingKnexEntityLoader } from './EnforcingKnexEntityLoader'; import { IEntityClass } from './Entity'; import { EntityAssociationLoader } from './EntityAssociationLoader'; +import { EntityConstructionUtils } from './EntityConstructionUtils'; +import { EntityInvalidationUtils } from './EntityInvalidationUtils'; import { EntityLoader } from './EntityLoader'; -import { EntityLoaderUtils } from './EntityLoaderUtils'; import { EntityPrivacyPolicy } from './EntityPrivacyPolicy'; import { EntityQueryContext } from './EntityQueryContext'; -import { KnexEntityLoader } from './KnexEntityLoader'; import { ViewerContext } from './ViewerContext'; /** @@ -233,11 +231,10 @@ export abstract class ReadonlyEntity< } /** - * Vend loader for loading an entity in a given query context. - * @param viewerContext - viewer context of loading user - * @param queryContext - query context in which to perform the load + * Utilites for entity invalidation. + * Calling into these should only be necessary in rare cases. */ - static loaderUtils< + static invalidationUtils< TMFields extends object, TMIDField extends keyof NonNullable>, TMViewerContext extends ViewerContext, @@ -265,7 +262,7 @@ export abstract class ReadonlyEntity< .getViewerScopedEntityCompanionForClass(this) .getQueryContextProvider() .getQueryContext(), - ): EntityLoaderUtils< + ): EntityInvalidationUtils< TMFields, TMIDField, TMViewerContext, @@ -273,59 +270,14 @@ export abstract class ReadonlyEntity< TMPrivacyPolicy, TMSelectedFields > { - return new EntityLoader(viewerContext, queryContext, this).utils(); + return new EntityLoader(viewerContext, queryContext, this).invalidationUtils(); } /** - * Vend knex loader for loading entities via non-data-loader methods in a given query context. - * @param viewerContext - viewer context of loading user - * @param queryContext - query context in which to perform the load - */ - static knexLoader< - TMFields extends object, - TMIDField extends keyof NonNullable>, - TMViewerContext extends ViewerContext, - TMViewerContext2 extends TMViewerContext, - TMEntity extends ReadonlyEntity, - TMPrivacyPolicy extends EntityPrivacyPolicy< - TMFields, - TMIDField, - TMViewerContext, - TMEntity, - TMSelectedFields - >, - TMSelectedFields extends keyof TMFields = keyof TMFields, - >( - this: IEntityClass< - TMFields, - TMIDField, - TMViewerContext, - TMEntity, - TMPrivacyPolicy, - TMSelectedFields - >, - viewerContext: TMViewerContext2, - queryContext: EntityQueryContext = viewerContext - .getViewerScopedEntityCompanionForClass(this) - .getQueryContextProvider() - .getQueryContext(), - ): EnforcingKnexEntityLoader< - TMFields, - TMIDField, - TMViewerContext, - TMEntity, - TMPrivacyPolicy, - TMSelectedFields - > { - return new KnexEntityLoader(viewerContext, queryContext, this).enforcing(); - } - - /** - * Vend knex loader for loading entities via non-data-loader methods in a given query context. - * @param viewerContext - viewer context of loading user - * @param queryContext - query context in which to perform the load + * Utilites for entity construction. + * Calling into these should only be necessary in rare cases. */ - static knexLoaderWithAuthorizationResults< + static constructionUtils< TMFields extends object, TMIDField extends keyof NonNullable>, TMViewerContext extends ViewerContext, @@ -353,7 +305,7 @@ export abstract class ReadonlyEntity< .getViewerScopedEntityCompanionForClass(this) .getQueryContextProvider() .getQueryContext(), - ): AuthorizationResultBasedKnexEntityLoader< + ): EntityConstructionUtils< TMFields, TMIDField, TMViewerContext, @@ -361,6 +313,6 @@ export abstract class ReadonlyEntity< TMPrivacyPolicy, TMSelectedFields > { - return new KnexEntityLoader(viewerContext, queryContext, this).withAuthorizationResults(); + return new EntityLoader(viewerContext, queryContext, this).constructionUtils(); } } diff --git a/packages/entity/src/ViewerScopedEntityCompanion.ts b/packages/entity/src/ViewerScopedEntityCompanion.ts index 04934074b..9fb16a4d1 100644 --- a/packages/entity/src/ViewerScopedEntityCompanion.ts +++ b/packages/entity/src/ViewerScopedEntityCompanion.ts @@ -5,7 +5,6 @@ import { ReadonlyEntity } from './ReadonlyEntity'; import { ViewerContext } from './ViewerContext'; import { ViewerScopedEntityLoaderFactory } from './ViewerScopedEntityLoaderFactory'; import { ViewerScopedEntityMutatorFactory } from './ViewerScopedEntityMutatorFactory'; -import { ViewerScopedKnexEntityLoaderFactory } from './ViewerScopedKnexEntityLoaderFactory'; import { IEntityMetricsAdapter } from './metrics/IEntityMetricsAdapter'; /** @@ -35,7 +34,7 @@ export class ViewerScopedEntityCompanion< TPrivacyPolicy, TSelectedFields >, - private readonly viewerContext: TViewerContext, + public readonly viewerContext: TViewerContext, ) {} /** @@ -55,23 +54,6 @@ export class ViewerScopedEntityCompanion< ); } - /** - * Vend a viewer-scoped knex entity loader. - */ - getKnexLoaderFactory(): ViewerScopedKnexEntityLoaderFactory< - TFields, - TIDField, - TViewerContext, - TEntity, - TPrivacyPolicy, - TSelectedFields - > { - return new ViewerScopedKnexEntityLoaderFactory( - this.entityCompanion.getKnexLoaderFactory(), - this.viewerContext, - ); - } - /** * Vend a viewer-scoped entity mutator factory. */ diff --git a/packages/entity/src/__tests__/AuthorizationResultBasedEntityLoader-constructor-test.ts b/packages/entity/src/__tests__/AuthorizationResultBasedEntityLoader-constructor-test.ts index 0cf69e8f8..f5d3dfac2 100644 --- a/packages/entity/src/__tests__/AuthorizationResultBasedEntityLoader-constructor-test.ts +++ b/packages/entity/src/__tests__/AuthorizationResultBasedEntityLoader-constructor-test.ts @@ -5,8 +5,9 @@ import { AuthorizationResultBasedEntityLoader } from '../AuthorizationResultBase import { Entity } from '../Entity'; import { EntityCompanionDefinition } from '../EntityCompanionProvider'; import { EntityConfiguration } from '../EntityConfiguration'; +import { EntityConstructionUtils } from '../EntityConstructionUtils'; import { StringField } from '../EntityFields'; -import { EntityLoaderUtils } from '../EntityLoaderUtils'; +import { EntityInvalidationUtils } from '../EntityInvalidationUtils'; import { EntityPrivacyPolicy, EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy'; import { ViewerContext } from '../ViewerContext'; import { EntityDataManager } from '../internal/EntityDataManager'; @@ -164,7 +165,13 @@ describe(AuthorizationResultBasedEntityLoader, () => { metricsAdapter, TestEntity.name, ); - const utils = new EntityLoaderUtils( + const invalidationUtils = new EntityInvalidationUtils( + testEntityConfiguration, + TestEntity, + dataManager, + metricsAdapter, + ); + const constructionUtils = new EntityConstructionUtils( viewerContext, queryContext, privacyPolicyEvaluationContext, @@ -172,7 +179,6 @@ describe(AuthorizationResultBasedEntityLoader, () => { TestEntity, /* entitySelectedFields */ undefined, privacyPolicy, - dataManager, metricsAdapter, ); const entityLoader = new AuthorizationResultBasedEntityLoader( @@ -181,7 +187,8 @@ describe(AuthorizationResultBasedEntityLoader, () => { TestEntity, dataManager, metricsAdapter, - utils, + invalidationUtils, + constructionUtils, ); let capturedThrownThing1: any; diff --git a/packages/entity/src/__tests__/AuthorizationResultBasedEntityLoader-test.ts b/packages/entity/src/__tests__/AuthorizationResultBasedEntityLoader-test.ts index c3297d6dc..9eed3a7ab 100644 --- a/packages/entity/src/__tests__/AuthorizationResultBasedEntityLoader-test.ts +++ b/packages/entity/src/__tests__/AuthorizationResultBasedEntityLoader-test.ts @@ -4,7 +4,8 @@ import { anyOfClass, anything, instance, mock, spy, verify, when } from 'ts-mock import { v4 as uuidv4 } from 'uuid'; import { AuthorizationResultBasedEntityLoader } from '../AuthorizationResultBasedEntityLoader'; -import { EntityLoaderUtils } from '../EntityLoaderUtils'; +import { EntityConstructionUtils } from '../EntityConstructionUtils'; +import { EntityInvalidationUtils } from '../EntityInvalidationUtils'; import { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy'; import { ViewerContext } from '../ViewerContext'; import { enforceResultsAsync } from '../entityUtils'; @@ -89,7 +90,13 @@ describe(AuthorizationResultBasedEntityLoader, () => { instance(mock()), TestEntity.name, ); - const utils = new EntityLoaderUtils( + const invalidationUtils = new EntityInvalidationUtils( + testEntityConfiguration, + TestEntity, + dataManager, + metricsAdapter, + ); + const constructionUtils = new EntityConstructionUtils( viewerContext, queryContext, privacyPolicyEvaluationContext, @@ -97,7 +104,6 @@ describe(AuthorizationResultBasedEntityLoader, () => { TestEntity, /* entitySelectedFields */ undefined, privacyPolicy, - dataManager, metricsAdapter, ); const entityLoader = new AuthorizationResultBasedEntityLoader( @@ -106,7 +112,8 @@ describe(AuthorizationResultBasedEntityLoader, () => { TestEntity, dataManager, metricsAdapter, - utils, + invalidationUtils, + constructionUtils, ); const entity = await enforceAsyncResult(entityLoader.loadByIDAsync(id1)); @@ -233,7 +240,13 @@ describe(AuthorizationResultBasedEntityLoader, () => { instance(mock()), TestEntity.name, ); - const utils = new EntityLoaderUtils( + const invalidationUtils = new EntityInvalidationUtils( + testEntityConfiguration, + TestEntity, + dataManager, + metricsAdapter, + ); + const constructionUtils = new EntityConstructionUtils( viewerContext, queryContext, privacyPolicyEvaluationContext, @@ -241,7 +254,6 @@ describe(AuthorizationResultBasedEntityLoader, () => { TestEntity, /* entitySelectedFields */ undefined, privacyPolicy, - dataManager, metricsAdapter, ); const entityLoader = new AuthorizationResultBasedEntityLoader( @@ -250,7 +262,8 @@ describe(AuthorizationResultBasedEntityLoader, () => { TestEntity, dataManager, metricsAdapter, - utils, + invalidationUtils, + constructionUtils, ); const entities = await enforceResultsAsync( @@ -372,7 +385,13 @@ describe(AuthorizationResultBasedEntityLoader, () => { instance(mock()), TestEntity.name, ); - const utils = new EntityLoaderUtils( + const invalidationUtils = new EntityInvalidationUtils( + testEntityConfiguration, + TestEntity, + dataManager, + metricsAdapter, + ); + const constructionUtils = new EntityConstructionUtils( viewerContext, queryContext, privacyPolicyEvaluationContext, @@ -380,7 +399,6 @@ describe(AuthorizationResultBasedEntityLoader, () => { TestEntity, /* entitySelectedFields */ undefined, privacyPolicy, - dataManager, metricsAdapter, ); const entityLoader = new AuthorizationResultBasedEntityLoader( @@ -389,7 +407,8 @@ describe(AuthorizationResultBasedEntityLoader, () => { TestEntity, dataManager, metricsAdapter, - utils, + invalidationUtils, + constructionUtils, ); const entity = await enforceAsyncResult(entityLoader.loadByIDAsync(id1)); verify( @@ -423,7 +442,13 @@ describe(AuthorizationResultBasedEntityLoader, () => { const dataManagerInstance = instance(dataManagerMock); const id1 = uuidv4(); - const utils = new EntityLoaderUtils( + const invalidationUtils = new EntityInvalidationUtils( + testEntityConfiguration, + TestEntity, + dataManagerInstance, + metricsAdapter, + ); + const constructionUtils = new EntityConstructionUtils( viewerContext, queryContext, privacyPolicyEvaluationContext, @@ -431,7 +456,6 @@ describe(AuthorizationResultBasedEntityLoader, () => { TestEntity, /* entitySelectedFields */ undefined, privacyPolicy, - dataManagerInstance, metricsAdapter, ); const entityLoader = new AuthorizationResultBasedEntityLoader( @@ -440,12 +464,13 @@ describe(AuthorizationResultBasedEntityLoader, () => { TestEntity, dataManagerInstance, metricsAdapter, - utils, + invalidationUtils, + constructionUtils, ); const date = new Date(); - await entityLoader.utils.invalidateFieldsAsync({ + await entityLoader.invalidationUtils.invalidateFieldsAsync({ customIdField: id1, testIndexedField: 'h1', intField: 5, @@ -526,7 +551,13 @@ describe(AuthorizationResultBasedEntityLoader, () => { }); const entityInstance = instance(entityMock); - const utils = new EntityLoaderUtils( + const invalidationUtils = new EntityInvalidationUtils( + testEntityConfiguration, + TestEntity, + dataManagerInstance, + metricsAdapter, + ); + const constructionUtils = new EntityConstructionUtils( viewerContext, queryContext, privacyPolicyEvaluationContext, @@ -534,7 +565,6 @@ describe(AuthorizationResultBasedEntityLoader, () => { TestEntity, /* entitySelectedFields */ undefined, privacyPolicy, - dataManagerInstance, metricsAdapter, ); const entityLoader = new AuthorizationResultBasedEntityLoader( @@ -543,9 +573,10 @@ describe(AuthorizationResultBasedEntityLoader, () => { TestEntity, dataManagerInstance, metricsAdapter, - utils, + invalidationUtils, + constructionUtils, ); - await entityLoader.utils.invalidateEntityAsync(entityInstance); + await entityLoader.invalidationUtils.invalidateEntityAsync(entityInstance); verify(dataManagerMock.invalidateKeyValuePairsAsync(anything())).once(); verify( @@ -620,7 +651,13 @@ describe(AuthorizationResultBasedEntityLoader, () => { const entityInstance = instance(entityMock); await new StubQueryContextProvider().runInTransactionAsync(async (queryContext) => { - const utils = new EntityLoaderUtils( + const invalidationUtils = new EntityInvalidationUtils( + testEntityConfiguration, + TestEntity, + dataManagerInstance, + metricsAdapter, + ); + const constructionUtils = new EntityConstructionUtils( viewerContext, queryContext, privacyPolicyEvaluationContext, @@ -628,7 +665,6 @@ describe(AuthorizationResultBasedEntityLoader, () => { TestEntity, /* entitySelectedFields */ undefined, privacyPolicy, - dataManagerInstance, metricsAdapter, ); const entityLoader = new AuthorizationResultBasedEntityLoader( @@ -637,9 +673,10 @@ describe(AuthorizationResultBasedEntityLoader, () => { TestEntity, dataManagerInstance, metricsAdapter, - utils, + invalidationUtils, + constructionUtils, ); - entityLoader.utils.invalidateEntityForTransaction(queryContext, entityInstance); + entityLoader.invalidationUtils.invalidateEntityForTransaction(queryContext, entityInstance); verify( dataManagerMock.invalidateKeyValuePairsForTransaction(queryContext, anything()), @@ -735,7 +772,13 @@ describe(AuthorizationResultBasedEntityLoader, () => { const privacyPolicy = instance(privacyPolicyMock); const dataManagerInstance = instance(dataManagerMock); - const utils = new EntityLoaderUtils( + const invalidationUtils = new EntityInvalidationUtils( + testEntityConfiguration, + TestEntity, + dataManagerInstance, + metricsAdapter, + ); + const constructionUtils = new EntityConstructionUtils( viewerContext, queryContext, privacyPolicyEvaluationContext, @@ -743,7 +786,6 @@ describe(AuthorizationResultBasedEntityLoader, () => { TestEntity, /* entitySelectedFields */ undefined, privacyPolicy, - dataManagerInstance, metricsAdapter, ); const entityLoader = new AuthorizationResultBasedEntityLoader( @@ -752,7 +794,8 @@ describe(AuthorizationResultBasedEntityLoader, () => { TestEntity, dataManagerInstance, metricsAdapter, - utils, + invalidationUtils, + constructionUtils, ); const entityResult = await entityLoader.loadByIDAsync(id1); @@ -787,7 +830,13 @@ describe(AuthorizationResultBasedEntityLoader, () => { const dataManagerInstance = instance(dataManagerMock); - const utils = new EntityLoaderUtils( + const invalidationUtils = new EntityInvalidationUtils( + testEntityConfiguration, + TestEntity, + dataManagerInstance, + metricsAdapter, + ); + const constructionUtils = new EntityConstructionUtils( viewerContext, queryContext, privacyPolicyEvaluationContext, @@ -795,7 +844,6 @@ describe(AuthorizationResultBasedEntityLoader, () => { TestEntity, /* entitySelectedFields */ undefined, privacyPolicy, - dataManagerInstance, metricsAdapter, ); const entityLoader = new AuthorizationResultBasedEntityLoader( @@ -804,7 +852,8 @@ describe(AuthorizationResultBasedEntityLoader, () => { TestEntity, dataManagerInstance, metricsAdapter, - utils, + invalidationUtils, + constructionUtils, ); const loadByValue = uuidv4(); diff --git a/packages/entity/src/__tests__/EntityCompanion-test.ts b/packages/entity/src/__tests__/EntityCompanion-test.ts index 8693d9678..54ff023aa 100644 --- a/packages/entity/src/__tests__/EntityCompanion-test.ts +++ b/packages/entity/src/__tests__/EntityCompanion-test.ts @@ -78,4 +78,22 @@ describe(EntityCompanion, () => { afterCommit: [localTriggers!.afterCommit![0]], }); }); + it('returns correct metrics adapter', () => { + const entityCompanionProvider = instance(mock()); + + const tableDataCoordinatorMock = mock>(); + when(tableDataCoordinatorMock.entityConfiguration).thenReturn(testEntityMTConfiguration); + + const metricsAdapterMock = mock(); + const metricsAdapterInstance = instance(metricsAdapterMock); + + const companion = new EntityCompanion( + entityCompanionProvider, + TestEntityWithMutationTriggers.defineCompanionDefinition(), + instance(tableDataCoordinatorMock), + metricsAdapterInstance, + ); + + expect(companion.getMetricsAdapter()).toBe(metricsAdapterInstance); + }); }); diff --git a/packages/entity/src/__tests__/EntityDatabaseAdapter-test.ts b/packages/entity/src/__tests__/EntityDatabaseAdapter-test.ts index 2762e9e15..5e5fb33a2 100644 --- a/packages/entity/src/__tests__/EntityDatabaseAdapter-test.ts +++ b/packages/entity/src/__tests__/EntityDatabaseAdapter-test.ts @@ -1,11 +1,7 @@ import { describe, expect, it } from '@jest/globals'; import { instance, mock } from 'ts-mockito'; -import { - EntityDatabaseAdapter, - TableFieldMultiValueEqualityCondition, - TableFieldSingleValueEqualityCondition, -} from '../EntityDatabaseAdapter'; +import { EntityDatabaseAdapter } from '../EntityDatabaseAdapter'; import { EntityQueryContext } from '../EntityQueryContext'; import { EntityDatabaseAdapterEmptyInsertResultError, @@ -23,31 +19,23 @@ class TestEntityDatabaseAdapter extends EntityDatabaseAdapter { - return this.fetchRawWhereResults; - } - - protected async fetchManyByFieldEqualityConjunctionInternalAsync( - _queryInterface: any, - _tableName: string, - _tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[], - _tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[], - ): Promise { - return this.fetchEqualityConditionResults; - } - protected async insertInternalAsync( _queryInterface: any, _tableName: string, @@ -237,28 +207,6 @@ describe(EntityDatabaseAdapter, () => { }); }); - describe('fetchManyByFieldEqualityConjunction', () => { - it('transforms object', async () => { - const queryContext = instance(mock(EntityQueryContext)); - const adapter = new TestEntityDatabaseAdapter({ - fetchEqualityConditionResults: [{ string_field: 'hello' }], - }); - const results = await adapter.fetchManyByFieldEqualityConjunctionAsync(queryContext, [], {}); - expect(results).toEqual([{ stringField: 'hello' }]); - }); - }); - - describe('fetchManyWithRawWhereClause', () => { - it('transforms object', async () => { - const queryContext = instance(mock(EntityQueryContext)); - const adapter = new TestEntityDatabaseAdapter({ - fetchRawWhereResults: [{ string_field: 'hello' }], - }); - const results = await adapter.fetchManyByRawWhereClauseAsync(queryContext, 'hello', [], {}); - expect(results).toEqual([{ stringField: 'hello' }]); - }); - }); - describe('insertAsync', () => { it('transforms object', async () => { const queryContext = instance(mock(EntityQueryContext)); diff --git a/packages/entity/src/__tests__/EntityLoader-test.ts b/packages/entity/src/__tests__/EntityLoader-test.ts index 87b02c0ae..373802905 100644 --- a/packages/entity/src/__tests__/EntityLoader-test.ts +++ b/packages/entity/src/__tests__/EntityLoader-test.ts @@ -2,8 +2,9 @@ import { describe, expect, it } from '@jest/globals'; import { AuthorizationResultBasedEntityLoader } from '../AuthorizationResultBasedEntityLoader'; import { EnforcingEntityLoader } from '../EnforcingEntityLoader'; +import { EntityConstructionUtils } from '../EntityConstructionUtils'; +import { EntityInvalidationUtils } from '../EntityInvalidationUtils'; import { EntityLoader } from '../EntityLoader'; -import { EntityLoaderUtils } from '../EntityLoaderUtils'; import { ViewerContext } from '../ViewerContext'; import { SimpleTestEntity } from '../utils/__testfixtures__/SimpleTestEntity'; import { createUnitTestEntityCompanionProvider } from '../utils/__testfixtures__/createUnitTestEntityCompanionProvider'; @@ -27,11 +28,23 @@ describe(EntityLoader, () => { }); }); - describe('utils', () => { - it('returns a instance of EntityLoaderUtils', async () => { + describe('invalidationUtils', () => { + it('returns a instance of EntityInvalidationUtils', async () => { const companionProvider = createUnitTestEntityCompanionProvider(); const viewerContext = new ViewerContext(companionProvider); - expect(SimpleTestEntity.loaderUtils(viewerContext)).toBeInstanceOf(EntityLoaderUtils); + expect(SimpleTestEntity.invalidationUtils(viewerContext)).toBeInstanceOf( + EntityInvalidationUtils, + ); + }); + }); + + describe('constructionUtils', () => { + it('returns a instance of EntityConstructionUtils', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new ViewerContext(companionProvider); + expect(SimpleTestEntity.constructionUtils(viewerContext)).toBeInstanceOf( + EntityConstructionUtils, + ); }); }); }); diff --git a/packages/entity/src/__tests__/EntityMutator-test.ts b/packages/entity/src/__tests__/EntityMutator-test.ts index d8e5e9c43..2bb6bb361 100644 --- a/packages/entity/src/__tests__/EntityMutator-test.ts +++ b/packages/entity/src/__tests__/EntityMutator-test.ts @@ -16,9 +16,9 @@ import { v4 as uuidv4 } from 'uuid'; import { AuthorizationResultBasedEntityLoader } from '../AuthorizationResultBasedEntityLoader'; import { EntityCompanionProvider } from '../EntityCompanionProvider'; import { EntityConfiguration } from '../EntityConfiguration'; +import { EntityConstructionUtils } from '../EntityConstructionUtils'; import { EntityDatabaseAdapter } from '../EntityDatabaseAdapter'; import { EntityLoaderFactory } from '../EntityLoaderFactory'; -import { EntityLoaderUtils } from '../EntityLoaderUtils'; import { EntityCascadingDeletionInfo, EntityMutationType, @@ -370,6 +370,12 @@ const createEntityMutatorFactory = ( ), ); const customStubDatabaseAdapterProvider: IEntityDatabaseAdapterProvider = { + getExtensionsKey() { + return 'CustomStubDatabaseAdapterProvider'; + }, + + installExtensions: () => {}, + getDatabaseAdapter>( _entityConfiguration: EntityConfiguration, ): EntityDatabaseAdapter { @@ -1499,9 +1505,9 @@ describe(EntityMutatorFactory, () => { keyof SimpleTestFields > >(AuthorizationResultBasedEntityLoader); - const entityLoaderUtilsMock = + const entityConstructionUtilsMock = mock< - EntityLoaderUtils< + EntityConstructionUtils< SimpleTestFields, 'id', ViewerContext, @@ -1509,9 +1515,9 @@ describe(EntityMutatorFactory, () => { SimpleTestEntityPrivacyPolicy, keyof SimpleTestFields > - >(EntityLoaderUtils); - when(entityLoaderUtilsMock.constructEntity(anything())).thenReturn(fakeEntity); - when(entityLoaderMock.utils).thenReturn(instance(entityLoaderUtilsMock)); + >(EntityConstructionUtils); + when(entityConstructionUtilsMock.constructEntity(anything())).thenReturn(fakeEntity); + when(entityLoaderMock.constructionUtils).thenReturn(instance(entityConstructionUtilsMock)); const entityLoader = instance(entityLoaderMock); const entityLoaderFactoryMock = @@ -1634,9 +1640,9 @@ describe(EntityMutatorFactory, () => { keyof SimpleTestFields > >(AuthorizationResultBasedEntityLoader); - const entityLoaderUtilsMock = + const entityConstructionUtilsMock = mock< - EntityLoaderUtils< + EntityConstructionUtils< SimpleTestFields, 'id', ViewerContext, @@ -1644,9 +1650,9 @@ describe(EntityMutatorFactory, () => { SimpleTestEntityPrivacyPolicy, keyof SimpleTestFields > - >(EntityLoaderUtils); - when(entityLoaderUtilsMock.constructEntity(anything())).thenReturn(fakeEntity); - when(entityLoaderMock.utils).thenReturn(instance(entityLoaderUtilsMock)); + >(EntityConstructionUtils); + when(entityConstructionUtilsMock.constructEntity(anything())).thenReturn(fakeEntity); + when(entityLoaderMock.constructionUtils).thenReturn(instance(entityConstructionUtilsMock)); const entityLoader = instance(entityLoaderMock); const entityLoaderFactoryMock = diff --git a/packages/entity/src/__tests__/EntitySecondaryCacheLoader-test.ts b/packages/entity/src/__tests__/EntitySecondaryCacheLoader-test.ts index abbc689a5..b8998199a 100644 --- a/packages/entity/src/__tests__/EntitySecondaryCacheLoader-test.ts +++ b/packages/entity/src/__tests__/EntitySecondaryCacheLoader-test.ts @@ -47,7 +47,6 @@ describe(EntitySecondaryCacheLoader, () => { const secondaryCacheLoader = new TestSecondaryRedisCacheLoader( secondaryEntityCache, SimpleTestEntity.loaderWithAuthorizationResults(vc1), - SimpleTestEntity.knexLoaderWithAuthorizationResults(vc1), ); await secondaryCacheLoader.loadManyAsync([loadParams]); @@ -71,13 +70,8 @@ describe(EntitySecondaryCacheLoader, () => { const secondaryEntityCache = instance(secondaryEntityCacheMock); const loader = SimpleTestEntity.loaderWithAuthorizationResults(vc1); - const knexLoader = SimpleTestEntity.knexLoaderWithAuthorizationResults(vc1); - const spiedPrivacyPolicy = spy(loader.utils['privacyPolicy']); - const secondaryCacheLoader = new TestSecondaryRedisCacheLoader( - secondaryEntityCache, - loader, - knexLoader, - ); + const spiedPrivacyPolicy = spy(loader.constructionUtils['privacyPolicy']); + const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(secondaryEntityCache, loader); const result = await secondaryCacheLoader.loadManyAsync([loadParams]); expect(result.get(loadParams)?.enforceValue().getID()).toEqual(createdEntity.getID()); @@ -105,12 +99,7 @@ describe(EntitySecondaryCacheLoader, () => { mock>(); const secondaryEntityCache = instance(secondaryEntityCacheMock); const loader = SimpleTestEntity.loaderWithAuthorizationResults(vc1); - const knexLoader = SimpleTestEntity.knexLoaderWithAuthorizationResults(vc1); - const secondaryCacheLoader = new TestSecondaryRedisCacheLoader( - secondaryEntityCache, - loader, - knexLoader, - ); + const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(secondaryEntityCache, loader); await secondaryCacheLoader.invalidateManyAsync([loadParams]); verify(secondaryEntityCacheMock.invalidateManyAsync(deepEqual([loadParams]))).once(); diff --git a/packages/entity/src/__tests__/GenericSecondaryEntityCache-test.ts b/packages/entity/src/__tests__/GenericSecondaryEntityCache-test.ts index 72c97a2bc..ebc9c993d 100644 --- a/packages/entity/src/__tests__/GenericSecondaryEntityCache-test.ts +++ b/packages/entity/src/__tests__/GenericSecondaryEntityCache-test.ts @@ -108,11 +108,7 @@ class TestSecondaryCacheLoader extends EntitySecondaryCacheLoader< const emptyMap = new Map(loadParamsArray.map((p) => [p, null])); return await mapMapAsync(emptyMap, async (_value, loadParams) => { return ( - ( - await this.knexEntityLoader.loadManyByFieldEqualityConjunctionAsync([ - { fieldName: 'intField', fieldValue: loadParams.intValue }, - ]) - )[0] + (await this.entityLoader.loadManyByFieldEqualingAsync('intField', loadParams.intValue))[0] ?.enforceValue() ?.getAllFields() ?? null ); @@ -134,7 +130,6 @@ describe(GenericSecondaryEntityCache, () => { (params) => `intValue.${params.intValue}`, ), TestEntity.loaderWithAuthorizationResults(viewerContext), - TestEntity.knexLoaderWithAuthorizationResults(viewerContext), ); const loadParams = { intValue: 1 }; @@ -171,7 +166,6 @@ describe(GenericSecondaryEntityCache, () => { (params) => `intValue.${params.intValue}`, ), TestEntity.loaderWithAuthorizationResults(viewerContext), - TestEntity.knexLoaderWithAuthorizationResults(viewerContext), ); const loadParams = { intValue: 2 }; diff --git a/packages/entity/src/__tests__/ReadonlyEntity-test.ts b/packages/entity/src/__tests__/ReadonlyEntity-test.ts index 97942f691..2180eb4df 100644 --- a/packages/entity/src/__tests__/ReadonlyEntity-test.ts +++ b/packages/entity/src/__tests__/ReadonlyEntity-test.ts @@ -3,11 +3,10 @@ import { instance, mock } from 'ts-mockito'; import { AuthorizationResultBasedEntityAssociationLoader } from '../AuthorizationResultBasedEntityAssociationLoader'; import { AuthorizationResultBasedEntityLoader } from '../AuthorizationResultBasedEntityLoader'; -import { AuthorizationResultBasedKnexEntityLoader } from '../AuthorizationResultBasedKnexEntityLoader'; import { EnforcingEntityAssociationLoader } from '../EnforcingEntityAssociationLoader'; import { EnforcingEntityLoader } from '../EnforcingEntityLoader'; -import { EnforcingKnexEntityLoader } from '../EnforcingKnexEntityLoader'; -import { EntityLoaderUtils } from '../EntityLoaderUtils'; +import { EntityConstructionUtils } from '../EntityConstructionUtils'; +import { EntityInvalidationUtils } from '../EntityInvalidationUtils'; import { ReadonlyEntity } from '../ReadonlyEntity'; import { ViewerContext } from '../ViewerContext'; import { SimpleTestEntity } from '../utils/__testfixtures__/SimpleTestEntity'; @@ -216,29 +215,23 @@ describe(ReadonlyEntity, () => { }); }); - describe('knexLoader', () => { - it('creates a new EnforcingKnexEntityLoader', async () => { + describe('invalidationUtils', () => { + it('creates a new EntityInvalidationUtils', async () => { const companionProvider = createUnitTestEntityCompanionProvider(); const viewerContext = new ViewerContext(companionProvider); - expect(SimpleTestEntity.knexLoader(viewerContext)).toBeInstanceOf(EnforcingKnexEntityLoader); - }); - }); - - describe('knexLoaderWithAuthorizationResults', () => { - it('creates a new AuthorizationResultBasedEntityLoader', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); - const viewerContext = new ViewerContext(companionProvider); - expect(SimpleTestEntity.knexLoaderWithAuthorizationResults(viewerContext)).toBeInstanceOf( - AuthorizationResultBasedKnexEntityLoader, + expect(SimpleTestEntity.invalidationUtils(viewerContext)).toBeInstanceOf( + EntityInvalidationUtils, ); }); }); - describe('loaderUtils', () => { - it('creates a new EntityLoaderUtils', async () => { + describe('constructionUtils', () => { + it('creates a new EntityConstructionUtils', async () => { const companionProvider = createUnitTestEntityCompanionProvider(); const viewerContext = new ViewerContext(companionProvider); - expect(SimpleTestEntity.loaderUtils(viewerContext)).toBeInstanceOf(EntityLoaderUtils); + expect(SimpleTestEntity.constructionUtils(viewerContext)).toBeInstanceOf( + EntityConstructionUtils, + ); }); }); }); diff --git a/packages/entity/src/__tests__/ViewerScopedEntityCompanion-test.ts b/packages/entity/src/__tests__/ViewerScopedEntityCompanion-test.ts index 59a7eb91e..f75949504 100644 --- a/packages/entity/src/__tests__/ViewerScopedEntityCompanion-test.ts +++ b/packages/entity/src/__tests__/ViewerScopedEntityCompanion-test.ts @@ -33,5 +33,6 @@ describe(ViewerScopedEntityCompanion, () => { expect(viewerScopedEntityCompanion.getMutatorFactory()).toBeInstanceOf( ViewerScopedEntityMutatorFactory, ); + expect(viewerScopedEntityCompanion.getMetricsAdapter()).toBeDefined(); }); }); diff --git a/packages/entity/src/__tests__/cases/TwoEntitySameTableDisjointRows-test.ts b/packages/entity/src/__tests__/cases/TwoEntitySameTableDisjointRows-test.ts index bd7226f06..438dfe065 100644 --- a/packages/entity/src/__tests__/cases/TwoEntitySameTableDisjointRows-test.ts +++ b/packages/entity/src/__tests__/cases/TwoEntitySameTableDisjointRows-test.ts @@ -50,23 +50,6 @@ describe('Two entities backed by the same table', () => { expect(failedManyResults[0]!.enforceError().message).toEqual( 'OneTestEntity must be instantiated with one data', ); - - const fieldEqualityConjunctionResults = await OneTestEntity.knexLoaderWithAuthorizationResults( - viewerContext, - ).loadManyByFieldEqualityConjunctionAsync([ - { - fieldName: 'common_other_field', - fieldValue: 'wat', - }, - ]); - const successfulfieldEqualityConjunctionResultsResults = successfulResults( - fieldEqualityConjunctionResults, - ); - const failedfieldEqualityConjunctionResultsResults = failedResults( - fieldEqualityConjunctionResults, - ); - expect(successfulfieldEqualityConjunctionResultsResults).toHaveLength(1); - expect(failedfieldEqualityConjunctionResultsResults).toHaveLength(1); }); }); diff --git a/packages/entity/src/index.ts b/packages/entity/src/index.ts index d939b4e9e..00d472641 100644 --- a/packages/entity/src/index.ts +++ b/packages/entity/src/index.ts @@ -7,7 +7,6 @@ export * from './AuthorizationResultBasedEntityAssociationLoader'; export * from './AuthorizationResultBasedEntityLoader'; export * from './AuthorizationResultBasedEntityMutator'; -export * from './AuthorizationResultBasedKnexEntityLoader'; export * from './ComposedEntityCacheAdapter'; export * from './ComposedSecondaryEntityCache'; export * from './EnforcingEntityAssociationLoader'; @@ -15,20 +14,20 @@ export * from './EnforcingEntityCreator'; export * from './EnforcingEntityDeleter'; export * from './EnforcingEntityLoader'; export * from './EnforcingEntityUpdater'; -export * from './EnforcingKnexEntityLoader'; export * from './Entity'; export * from './EntityAssociationLoader'; export * from './EntityCompanion'; export * from './EntityCompanionProvider'; export * from './EntityConfiguration'; +export * from './EntityConstructionUtils'; export * from './EntityCreator'; export * from './EntityDatabaseAdapter'; export * from './EntityDeleter'; export * from './EntityFieldDefinition'; export * from './EntityFields'; +export * from './EntityInvalidationUtils'; export * from './EntityLoader'; export * from './EntityLoaderFactory'; -export * from './EntityLoaderUtils'; export * from './EntityMutationInfo'; export * from './EntityMutationTriggerConfiguration'; export * from './EntityMutationValidatorConfiguration'; @@ -45,15 +44,12 @@ export * from './IEntityCacheAdapter'; export * from './IEntityCacheAdapterProvider'; export * from './IEntityDatabaseAdapterProvider'; export * from './IEntityGenericCacher'; -export * from './KnexEntityLoader'; -export * from './KnexEntityLoaderFactory'; export * from './ReadonlyEntity'; export * from './ViewerContext'; export * from './ViewerScopedEntityCompanion'; export * from './ViewerScopedEntityCompanionProvider'; export * from './ViewerScopedEntityLoaderFactory'; export * from './ViewerScopedEntityMutatorFactory'; -export * from './ViewerScopedKnexEntityLoaderFactory'; export * from './errors/EntityCacheAdapterError'; export * from './errors/EntityDatabaseAdapterError'; export * from './errors/EntityError'; @@ -64,7 +60,6 @@ export * from './internal/CompositeFieldHolder'; export * from './internal/CompositeFieldValueMap'; export * from './internal/EntityDataManager'; export * from './internal/EntityFieldTransformationUtils'; -export * from './internal/EntityKnexDataManager'; export * from './internal/EntityLoadInterfaces'; export * from './internal/EntityTableDataCoordinator'; export * from './internal/ReadThroughEntityCache'; @@ -81,7 +76,6 @@ export * from './rules/AlwaysSkipPrivacyPolicyRule'; export * from './rules/EvaluateIfEntityFieldPredicatePrivacyPolicyRule'; export * from './rules/PrivacyPolicyRule'; export * from './utils/EntityCreationUtils'; -export * from './utils/EntityPrivacyUtils'; export * from './utils/mergeEntityMutationTriggerConfigurations'; export * from './utils/collections/maps'; export * from './utils/collections/SerializableKeyMap'; diff --git a/packages/entity/src/internal/EntityTableDataCoordinator.ts b/packages/entity/src/internal/EntityTableDataCoordinator.ts index 5df415b72..b5abd74d9 100644 --- a/packages/entity/src/internal/EntityTableDataCoordinator.ts +++ b/packages/entity/src/internal/EntityTableDataCoordinator.ts @@ -5,7 +5,6 @@ import { IEntityCacheAdapter } from '../IEntityCacheAdapter'; import { IEntityCacheAdapterProvider } from '../IEntityCacheAdapterProvider'; import { IEntityDatabaseAdapterProvider } from '../IEntityDatabaseAdapterProvider'; import { EntityDataManager } from './EntityDataManager'; -import { EntityKnexDataManager } from './EntityKnexDataManager'; import { ReadThroughEntityCache } from './ReadThroughEntityCache'; import { IEntityMetricsAdapter } from '../metrics/IEntityMetricsAdapter'; @@ -23,15 +22,14 @@ export class EntityTableDataCoordinator< readonly databaseAdapter: EntityDatabaseAdapter; readonly cacheAdapter: IEntityCacheAdapter; readonly dataManager: EntityDataManager; - readonly knexDataManager: EntityKnexDataManager; constructor( readonly entityConfiguration: EntityConfiguration, databaseAdapterProvider: IEntityDatabaseAdapterProvider, cacheAdapterProvider: IEntityCacheAdapterProvider, private readonly queryContextProvider: EntityQueryContextProvider, - metricsAdapter: IEntityMetricsAdapter, - entityClassName: string, + public readonly metricsAdapter: IEntityMetricsAdapter, + public readonly entityClassName: string, ) { this.databaseAdapter = databaseAdapterProvider.getDatabaseAdapter(entityConfiguration); this.cacheAdapter = cacheAdapterProvider.getCacheAdapter(entityConfiguration); @@ -42,11 +40,6 @@ export class EntityTableDataCoordinator< metricsAdapter, entityClassName, ); - this.knexDataManager = new EntityKnexDataManager( - this.databaseAdapter, - metricsAdapter, - entityClassName, - ); } getQueryContextProvider(): EntityQueryContextProvider { diff --git a/packages/entity/src/metrics/__tests__/EntityMetricsUtils-test.ts b/packages/entity/src/metrics/__tests__/EntityMetricsUtils-test.ts new file mode 100644 index 000000000..e568d2c1f --- /dev/null +++ b/packages/entity/src/metrics/__tests__/EntityMetricsUtils-test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from '@jest/globals'; +import { anyNumber, deepEqual, instance, mock, verify, when } from 'ts-mockito'; + +import { EntityQueryContext } from '../../EntityQueryContext'; +import { + timeAndLogLoadEventAsync, + timeAndLogLoadMapEventAsync, + timeAndLogMutationEventAsync, +} from '../EntityMetricsUtils'; +import { + EntityMetricsLoadType, + EntityMetricsMutationType, + IEntityMetricsAdapter, +} from '../IEntityMetricsAdapter'; + +describe(timeAndLogLoadEventAsync, () => { + it('returns the result from the wrapped promise and logs', async () => { + const metricsAdapterMock = mock(); + const metricsAdapter = instance(metricsAdapterMock); + + const queryContextMock = mock(); + when(queryContextMock.isInTransaction()).thenReturn(false); + const queryContext = instance(queryContextMock); + + const expectedResult = [{ id: 1 }, { id: 2 }]; + + const result = await timeAndLogLoadEventAsync( + metricsAdapter, + EntityMetricsLoadType.LOAD_MANY, + 'TestEntity', + queryContext, + )(Promise.resolve(expectedResult)); + + expect(result).toBe(expectedResult); + + verify( + metricsAdapterMock.logDataManagerLoadEvent( + deepEqual({ + type: EntityMetricsLoadType.LOAD_MANY, + isInTransaction: false, + entityClassName: 'TestEntity', + duration: anyNumber(), + count: expectedResult.length, + }), + ), + ).once(); + }); +}); + +describe(timeAndLogLoadMapEventAsync, () => { + it('returns the result from the wrapped promise and logs with count summed across map values', async () => { + const metricsAdapterMock = mock(); + const metricsAdapter = instance(metricsAdapterMock); + + const queryContextMock = mock(); + when(queryContextMock.isInTransaction()).thenReturn(false); + const queryContext = instance(queryContextMock); + + const key1 = { serialize: () => 'key1' }; + const key2 = { serialize: () => 'key2' }; + const expectedResult = new Map([ + [key1, [{ id: 1 }, { id: 2 }]], + [key2, [{ id: 3 }]], + ]); + + const result = await timeAndLogLoadMapEventAsync( + metricsAdapter, + EntityMetricsLoadType.LOAD_MANY, + 'TestEntity', + queryContext, + )(Promise.resolve(expectedResult)); + + expect(result).toBe(expectedResult); + + verify( + metricsAdapterMock.logDataManagerLoadEvent( + deepEqual({ + type: EntityMetricsLoadType.LOAD_MANY, + isInTransaction: false, + entityClassName: 'TestEntity', + duration: anyNumber(), + count: 3, + }), + ), + ).once(); + }); +}); + +describe(timeAndLogMutationEventAsync, () => { + it('returns the result from the wrapped promise and logs', async () => { + const metricsAdapterMock = mock(); + const metricsAdapter = instance(metricsAdapterMock); + + const queryContextMock = mock(); + when(queryContextMock.isInTransaction()).thenReturn(true); + const queryContext = instance(queryContextMock); + + const expectedResult = { id: 1, name: 'created' }; + + const result = await timeAndLogMutationEventAsync( + metricsAdapter, + EntityMetricsMutationType.CREATE, + 'TestEntity', + queryContext, + )(Promise.resolve(expectedResult)); + + expect(result).toBe(expectedResult); + + verify( + metricsAdapterMock.logMutatorMutationEvent( + deepEqual({ + type: EntityMetricsMutationType.CREATE, + isInTransaction: true, + entityClassName: 'TestEntity', + duration: anyNumber(), + }), + ), + ).once(); + }); +}); diff --git a/packages/entity/src/utils/__testfixtures__/StubDatabaseAdapter.ts b/packages/entity/src/utils/__testfixtures__/StubDatabaseAdapter.ts index 029513e73..c9ec13a9d 100644 --- a/packages/entity/src/utils/__testfixtures__/StubDatabaseAdapter.ts +++ b/packages/entity/src/utils/__testfixtures__/StubDatabaseAdapter.ts @@ -2,13 +2,7 @@ import invariant from 'invariant'; import { v7 as uuidv7 } from 'uuid'; import { EntityConfiguration } from '../../EntityConfiguration'; -import { - EntityDatabaseAdapter, - OrderByOrdering, - TableFieldMultiValueEqualityCondition, - TableFieldSingleValueEqualityCondition, - TableQuerySelectionModifiers, -} from '../../EntityDatabaseAdapter'; +import { EntityDatabaseAdapter } from '../../EntityDatabaseAdapter'; import { IntField, StringField } from '../../EntityFields'; import { FieldTransformerMap, @@ -84,103 +78,6 @@ export class StubDatabaseAdapter< return [...results]; } - private static compareByOrderBys( - orderBys: { - columnName: string; - order: OrderByOrdering; - }[], - objectA: { [key: string]: any }, - objectB: { [key: string]: any }, - ): 0 | 1 | -1 { - if (orderBys.length === 0) { - return 0; - } - - const currentOrderBy = orderBys[0]!; - const aField = objectA[currentOrderBy.columnName]; - const bField = objectB[currentOrderBy.columnName]; - switch (currentOrderBy.order) { - case OrderByOrdering.DESCENDING: { - // simulate NULLS FIRST for DESC - if (aField === null && bField === null) { - return 0; - } else if (aField === null) { - return -1; - } else if (bField === null) { - return 1; - } - - return aField > bField - ? -1 - : aField < bField - ? 1 - : this.compareByOrderBys(orderBys.slice(1), objectA, objectB); - } - case OrderByOrdering.ASCENDING: { - // simulate NULLS LAST for ASC - if (aField === null && bField === null) { - return 0; - } else if (bField === null) { - return -1; - } else if (aField === null) { - return 1; - } - - return bField > aField - ? -1 - : bField < aField - ? 1 - : this.compareByOrderBys(orderBys.slice(1), objectA, objectB); - } - } - } - - protected async fetchManyByFieldEqualityConjunctionInternalAsync( - _queryInterface: any, - tableName: string, - tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[], - tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[], - querySelectionModifiers: TableQuerySelectionModifiers, - ): Promise { - let filteredObjects = this.getObjectCollectionForTable(tableName); - for (const { tableField, tableValue } of tableFieldSingleValueEqualityOperands) { - filteredObjects = filteredObjects.filter((obj) => obj[tableField] === tableValue); - } - - for (const { tableField, tableValues } of tableFieldMultiValueEqualityOperands) { - filteredObjects = filteredObjects.filter((obj) => tableValues.includes(obj[tableField])); - } - - const orderBy = querySelectionModifiers.orderBy; - if (orderBy !== undefined) { - filteredObjects = filteredObjects.sort((a, b) => - StubDatabaseAdapter.compareByOrderBys(orderBy, a, b), - ); - } - - const offset = querySelectionModifiers.offset; - if (offset !== undefined) { - filteredObjects = filteredObjects.slice(offset); - } - - const limit = querySelectionModifiers.limit; - if (limit !== undefined) { - filteredObjects = filteredObjects.slice(0, 0 + limit); - } - - return filteredObjects; - } - - protected fetchManyByRawWhereClauseInternalAsync( - _queryInterface: any, - _tableName: string, - _rawWhereClause: string, - _bindings: object | any[], - _querySelectionModifiers: TableQuerySelectionModifiers, - ): Promise { - throw new Error('Raw WHERE clauses not supported for StubDatabaseAdapter'); - } - private generateRandomID(): any { const idSchemaField = this.entityConfiguration2.schema.get(this.entityConfiguration2.idField); invariant( diff --git a/packages/entity/src/utils/__testfixtures__/StubDatabaseAdapterProvider.ts b/packages/entity/src/utils/__testfixtures__/StubDatabaseAdapterProvider.ts index 15c7a64e2..736ffe833 100644 --- a/packages/entity/src/utils/__testfixtures__/StubDatabaseAdapterProvider.ts +++ b/packages/entity/src/utils/__testfixtures__/StubDatabaseAdapterProvider.ts @@ -4,6 +4,14 @@ import { IEntityDatabaseAdapterProvider } from '../../IEntityDatabaseAdapterProv import { StubDatabaseAdapter } from '../__testfixtures__/StubDatabaseAdapter'; export class StubDatabaseAdapterProvider implements IEntityDatabaseAdapterProvider { + getExtensionsKey(): string { + return 'StubDatabaseAdapterProvider'; + } + + installExtensions(): void { + // No-op + } + private readonly objectCollection = new Map(); getDatabaseAdapter, TIDField extends keyof TFields>( diff --git a/yarn.lock b/yarn.lock index 34f103fb9..975c23157 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2841,6 +2841,7 @@ __metadata: "@jest/globals": "npm:30.2.0" knex: "npm:^3.1.0" pg: "npm:8.18.0" + ts-mockito: "npm:2.6.1" typescript: "npm:5.9.3" languageName: unknown linkType: soft