diff --git a/packages/entity-database-adapter-knex-testing-utils/src/StubPostgresDatabaseAdapter.ts b/packages/entity-database-adapter-knex-testing-utils/src/StubPostgresDatabaseAdapter.ts index b4b4c9d90..090f926c8 100644 --- a/packages/entity-database-adapter-knex-testing-utils/src/StubPostgresDatabaseAdapter.ts +++ b/packages/entity-database-adapter-knex-testing-utils/src/StubPostgresDatabaseAdapter.ts @@ -89,6 +89,21 @@ export class StubPostgresDatabaseAdapter< return [...results]; } + protected async fetchOneWhereInternalAsync( + queryInterface: any, + tableName: string, + tableColumns: readonly string[], + tableTuple: readonly any[], + ): Promise { + const results = await this.fetchManyWhereInternalAsync( + queryInterface, + tableName, + tableColumns, + [tableTuple], + ); + return results[0] ?? null; + } + private static compareByOrderBys( orderBys: { columnName: string; diff --git a/packages/entity-database-adapter-knex-testing-utils/src/__tests__/StubPostgresDatabaseAdapter-test.ts b/packages/entity-database-adapter-knex-testing-utils/src/__tests__/StubPostgresDatabaseAdapter-test.ts index fae466095..73b3cfc25 100644 --- a/packages/entity-database-adapter-knex-testing-utils/src/__tests__/StubPostgresDatabaseAdapter-test.ts +++ b/packages/entity-database-adapter-knex-testing-utils/src/__tests__/StubPostgresDatabaseAdapter-test.ts @@ -181,6 +181,108 @@ describe(StubPostgresDatabaseAdapter, () => { }); }); + describe('fetchOneWhereAsync', () => { + it('fetches one where single', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const databaseAdapter = new StubPostgresDatabaseAdapter( + testEntityConfiguration, + StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore( + testEntityConfiguration, + new Map([ + [ + testEntityConfiguration.tableName, + [ + { + customIdField: 'hello', + testIndexedField: 'h1', + intField: 5, + stringField: 'huh', + dateField: new Date(), + nullableField: null, + }, + { + customIdField: 'world', + testIndexedField: 'h2', + intField: 3, + stringField: 'huh', + dateField: new Date(), + nullableField: null, + }, + ], + ], + ]), + ), + ); + + const result = await databaseAdapter.fetchOneWhereAsync( + queryContext, + new SingleFieldHolder('stringField'), + new SingleFieldValueHolder('huh'), + ); + expect(result).toMatchObject({ + stringField: 'huh', + }); + }); + + it('returns null when no record found', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const databaseAdapter = new StubPostgresDatabaseAdapter( + testEntityConfiguration, + new Map(), + ); + + const result = await databaseAdapter.fetchOneWhereAsync( + queryContext, + new SingleFieldHolder('stringField'), + new SingleFieldValueHolder('huh'), + ); + expect(result).toBeNull(); + }); + + it('fetches one where composite', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const databaseAdapter = new StubPostgresDatabaseAdapter( + testEntityConfiguration, + StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore( + testEntityConfiguration, + new Map([ + [ + testEntityConfiguration.tableName, + [ + { + customIdField: 'hello', + testIndexedField: 'h1', + intField: 5, + stringField: 'huh', + dateField: new Date(), + nullableField: null, + }, + { + customIdField: 'world', + testIndexedField: 'h2', + intField: 5, + stringField: 'huh', + dateField: new Date(), + nullableField: null, + }, + ], + ], + ]), + ), + ); + + const result = await databaseAdapter.fetchOneWhereAsync( + queryContext, + new CompositeFieldHolder(['stringField', 'intField']), + new CompositeFieldValueHolder({ stringField: 'huh', intField: 5 }), + ); + expect(result).toMatchObject({ + stringField: 'huh', + intField: 5, + }); + }); + }); + describe('fetchManyByFieldEqualityConjunctionAsync', () => { it('supports conjuntions and query modifiers', async () => { const queryContext = instance(mock(EntityQueryContext)); diff --git a/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts b/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts index 50d5218cf..126837c2b 100644 --- a/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts +++ b/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts @@ -87,6 +87,25 @@ export class PostgresEntityDatabaseAdapter< ); } + protected async fetchOneWhereInternalAsync( + queryInterface: Knex, + tableName: string, + tableColumns: readonly string[], + tableTuple: readonly any[], + ): Promise { + const results = await this.fetchManyByFieldEqualityConjunctionInternalAsync( + queryInterface, + tableName, + tableColumns.map((column, index) => ({ + tableField: column, + tableValue: tableTuple[index], + })), + [], + { limit: 1, orderBy: undefined, offset: undefined }, + ); + return results[0] ?? null; + } + private applyQueryModifiersToQueryOrderByRaw( query: Knex.QueryBuilder, querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw, 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 4200f30a8..6c0df0856 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 @@ -306,6 +306,42 @@ describe('postgres entity integration', () => { }); }); + describe('single field value loading (fetchOneWhereInternalAsync)', () => { + it('supports one loading', async () => { + const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'hello') + .setField('hasACat', false) + .setField('hasADog', true) + .createAsync(), + ); + + await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'world') + .setField('hasACat', false) + .setField('hasADog', true) + .createAsync(), + ); + + await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'wat') + .setField('hasACat', false) + .setField('hasADog', false) + .createAsync(), + ); + + const result = await PostgresTestEntity.loaderWithAuthorizationResults(vc1)[ + 'loadOneByFieldEqualingAsync' + ]('hasACat', false); + expect(result?.enforceValue()).not.toBeNull(); + expect(result?.enforceValue().getField('hasACat')).toBe(false); + }); + }); + it('supports single field and composite field equality loading', async () => { const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); diff --git a/packages/entity-database-adapter-knex/src/__tests__/BasePostgresEntityDatabaseAdapter-test.ts b/packages/entity-database-adapter-knex/src/__tests__/BasePostgresEntityDatabaseAdapter-test.ts index 2264ef2c9..adc4037de 100644 --- a/packages/entity-database-adapter-knex/src/__tests__/BasePostgresEntityDatabaseAdapter-test.ts +++ b/packages/entity-database-adapter-knex/src/__tests__/BasePostgresEntityDatabaseAdapter-test.ts @@ -14,6 +14,7 @@ class TestEntityDatabaseAdapter extends BasePostgresEntityDatabaseAdapter< 'customIdField' > { private readonly fetchResults: object[]; + private readonly fetchOneResult: object | null; private readonly insertResults: object[]; private readonly updateResults: object[]; private readonly fetchEqualityConditionResults: object[]; @@ -23,6 +24,7 @@ class TestEntityDatabaseAdapter extends BasePostgresEntityDatabaseAdapter< constructor({ fetchResults = [], + fetchOneResult = null, insertResults = [], updateResults = [], fetchEqualityConditionResults = [], @@ -31,6 +33,7 @@ class TestEntityDatabaseAdapter extends BasePostgresEntityDatabaseAdapter< deleteCount = 0, }: { fetchResults?: object[]; + fetchOneResult?: object | null; insertResults?: object[]; updateResults?: object[]; fetchEqualityConditionResults?: object[]; @@ -40,6 +43,7 @@ class TestEntityDatabaseAdapter extends BasePostgresEntityDatabaseAdapter< }) { super(testEntityConfiguration); this.fetchResults = fetchResults; + this.fetchOneResult = fetchOneResult; this.insertResults = insertResults; this.updateResults = updateResults; this.fetchEqualityConditionResults = fetchEqualityConditionResults; @@ -61,6 +65,15 @@ class TestEntityDatabaseAdapter extends BasePostgresEntityDatabaseAdapter< return this.fetchResults; } + protected async fetchOneWhereInternalAsync( + _queryInterface: any, + _tableName: string, + _tableColumns: readonly string[], + _tableTuple: readonly any[], + ): Promise { + return this.fetchOneResult; + } + protected async fetchManyByRawWhereClauseInternalAsync( _queryInterface: any, _tableName: string, diff --git a/packages/entity-database-adapter-knex/src/__tests__/fixtures/StubPostgresDatabaseAdapter.ts b/packages/entity-database-adapter-knex/src/__tests__/fixtures/StubPostgresDatabaseAdapter.ts index 77993664b..fd3beb7d9 100644 --- a/packages/entity-database-adapter-knex/src/__tests__/fixtures/StubPostgresDatabaseAdapter.ts +++ b/packages/entity-database-adapter-knex/src/__tests__/fixtures/StubPostgresDatabaseAdapter.ts @@ -90,6 +90,21 @@ export class StubPostgresDatabaseAdapter< return [...results]; } + protected async fetchOneWhereInternalAsync( + queryInterface: any, + tableName: string, + tableColumns: readonly string[], + tableTuple: readonly any[], + ): Promise { + const results = await this.fetchManyWhereInternalAsync( + queryInterface, + tableName, + tableColumns, + [tableTuple], + ); + return results[0] ?? null; + } + private static compareByOrderBys( orderBys: { columnName: string; diff --git a/packages/entity-database-adapter-knex/src/index.ts b/packages/entity-database-adapter-knex/src/index.ts index 8e5719ae6..cd822cfbe 100644 --- a/packages/entity-database-adapter-knex/src/index.ts +++ b/packages/entity-database-adapter-knex/src/index.ts @@ -22,4 +22,3 @@ 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-example/src/adapters/InMemoryDatabaseAdapter.ts b/packages/entity-example/src/adapters/InMemoryDatabaseAdapter.ts index 3a00cc6cc..216fd3865 100644 --- a/packages/entity-example/src/adapters/InMemoryDatabaseAdapter.ts +++ b/packages/entity-example/src/adapters/InMemoryDatabaseAdapter.ts @@ -55,6 +55,21 @@ class InMemoryDatabaseAdapter< return [...results]; } + protected override async fetchOneWhereInternalAsync( + queryInterface: any, + tableName: string, + tableColumns: readonly string[], + tableTuple: readonly any[], + ): Promise { + const results = await this.fetchManyWhereInternalAsync( + queryInterface, + tableName, + tableColumns, + [tableTuple], + ); + return results[0] ?? null; + } + protected async insertInternalAsync( _queryInterface: any, _tableName: string, diff --git a/packages/entity-testing-utils/src/StubDatabaseAdapter.ts b/packages/entity-testing-utils/src/StubDatabaseAdapter.ts index e474512cf..3a9e7a8cf 100644 --- a/packages/entity-testing-utils/src/StubDatabaseAdapter.ts +++ b/packages/entity-testing-utils/src/StubDatabaseAdapter.ts @@ -79,6 +79,21 @@ export class StubDatabaseAdapter< return [...results]; } + protected async fetchOneWhereInternalAsync( + queryInterface: any, + tableName: string, + tableColumns: readonly string[], + tableTuple: readonly any[], + ): Promise { + const results = await this.fetchManyWhereInternalAsync( + queryInterface, + tableName, + tableColumns, + [tableTuple], + ); + return results[0] ?? null; + } + private generateRandomID(): any { const idSchemaField = this.entityConfiguration2.schema.get(this.entityConfiguration2.idField); invariant( diff --git a/packages/entity-testing-utils/src/__tests__/StubDatabaseAdapter-test.ts b/packages/entity-testing-utils/src/__tests__/StubDatabaseAdapter-test.ts index 8daaa9038..0bfbdbb6d 100644 --- a/packages/entity-testing-utils/src/__tests__/StubDatabaseAdapter-test.ts +++ b/packages/entity-testing-utils/src/__tests__/StubDatabaseAdapter-test.ts @@ -124,6 +124,108 @@ describe(StubDatabaseAdapter, () => { }); }); + describe('fetchOneWhereAsync', () => { + it('fetches one where single', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const databaseAdapter = new StubDatabaseAdapter( + testEntityConfiguration, + StubDatabaseAdapter.convertFieldObjectsToDataStore( + testEntityConfiguration, + new Map([ + [ + testEntityConfiguration.tableName, + [ + { + customIdField: 'hello', + testIndexedField: 'h1', + intField: 5, + stringField: 'huh', + dateField: new Date(), + nullableField: null, + }, + { + customIdField: 'world', + testIndexedField: 'h2', + intField: 3, + stringField: 'huh', + dateField: new Date(), + nullableField: null, + }, + ], + ], + ]), + ), + ); + + const result = await databaseAdapter.fetchOneWhereAsync( + queryContext, + new SingleFieldHolder('stringField'), + new SingleFieldValueHolder('huh'), + ); + expect(result).toMatchObject({ + stringField: 'huh', + }); + }); + + it('returns null when no record found', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const databaseAdapter = new StubDatabaseAdapter( + testEntityConfiguration, + new Map(), + ); + + const result = await databaseAdapter.fetchOneWhereAsync( + queryContext, + new SingleFieldHolder('stringField'), + new SingleFieldValueHolder('huh'), + ); + expect(result).toBeNull(); + }); + + it('fetches one where composite', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const databaseAdapter = new StubDatabaseAdapter( + testEntityConfiguration, + StubDatabaseAdapter.convertFieldObjectsToDataStore( + testEntityConfiguration, + new Map([ + [ + testEntityConfiguration.tableName, + [ + { + customIdField: 'hello', + testIndexedField: 'h1', + intField: 5, + stringField: 'huh', + dateField: new Date(), + nullableField: null, + }, + { + customIdField: 'world', + testIndexedField: 'h2', + intField: 5, + stringField: 'huh', + dateField: new Date(), + nullableField: null, + }, + ], + ], + ]), + ), + ); + + const result = await databaseAdapter.fetchOneWhereAsync( + queryContext, + new CompositeFieldHolder(['stringField', 'intField']), + new CompositeFieldValueHolder({ stringField: 'huh', intField: 5 }), + ); + expect(result).toMatchObject({ + stringField: 'huh', + intField: 5, + }); + }); + }); + describe('insertAsync', () => { it('inserts a record', async () => { const queryContext = instance(mock(EntityQueryContext)); diff --git a/packages/entity/src/AuthorizationResultBasedEntityLoader.ts b/packages/entity/src/AuthorizationResultBasedEntityLoader.ts index f2cce2d46..4bf0bdf23 100644 --- a/packages/entity/src/AuthorizationResultBasedEntityLoader.ts +++ b/packages/entity/src/AuthorizationResultBasedEntityLoader.ts @@ -145,6 +145,41 @@ export class AuthorizationResultBasedEntityLoader< return entityResultsForFieldValue; } + /** + * Load one entity where fieldName equals fieldValue, or null if no entity exists matching the condition. + * Not cached or coalesced, and not guaranteed to be deterministic if multiple entities match the condition. + * + * Only used when evaluating EntityEdgeDeletionAuthorizationInferenceBehavior.ONE_IMPLIES_ALL. + * + * @param fieldName - entity field being queried + * @param fieldValue - fieldName field value being queried + * @returns entity result matching the query for fieldValue. Returns null if no entity matches the query. + * @throws EntityNotAuthorizedError when viewer is not authorized to view the returned entity + * + * @internal + */ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore -- this method is used in EntityPrivacyUtils, but is not intended to be part of the public API of this class, so it is marked as private. + private async loadOneByFieldEqualingAsync>( + fieldName: N, + fieldValue: NonNullable, + ): Promise | null> { + const { loadKey, loadValue } = this.validateFieldAndValueAndConvertToHolders( + fieldName, + fieldValue, + ); + const result = await this.dataManager.loadOneEqualingAsync( + this.queryContext, + loadKey, + loadValue, + ); + if (!result) { + return null; + } + + return await this.constructionUtils.constructAndAuthorizeEntityAsync(result); + } + /** * Authorization-result-based version of the EnforcingEntityLoader method by the same name. * @returns array of entity results that match the query for compositeFieldValue, where result error can be UnauthorizedError @@ -286,6 +321,21 @@ export class AuthorizationResultBasedEntityLoader< }; } + private validateFieldAndValueAndConvertToHolders>( + fieldName: N, + fieldValue: NonNullable, + ): { + loadKey: SingleFieldHolder; + loadValue: SingleFieldValueHolder; + } { + this.constructionUtils.validateFieldAndValues(fieldName, [fieldValue]); + + return { + loadKey: new SingleFieldHolder(fieldName), + loadValue: new SingleFieldValueHolder(fieldValue), + }; + } + private validateCompositeFieldAndValuesAndConvertToHolders< N extends EntityCompositeField>, >( diff --git a/packages/entity/src/EntityConstructionUtils.ts b/packages/entity/src/EntityConstructionUtils.ts index 89b9af907..69c931149 100644 --- a/packages/entity/src/EntityConstructionUtils.ts +++ b/packages/entity/src/EntityConstructionUtils.ts @@ -100,23 +100,36 @@ export class EntityConstructionUtils< ): Promise[]> { const uncheckedEntityResults = this.tryConstructEntities(fieldObjects); return await Promise.all( - uncheckedEntityResults.map(async (uncheckedEntityResult) => { - if (!uncheckedEntityResult.ok) { - return uncheckedEntityResult; - } - return await asyncResult( - this.privacyPolicy.authorizeReadAsync( - this.viewerContext, - this.queryContext, - this.privacyPolicyEvaluationContext, - uncheckedEntityResult.value, - this.metricsAdapter, - ), - ); - }), + uncheckedEntityResults.map((uncheckedEntityResult) => + this.authorizeEntityResultAsync(uncheckedEntityResult), + ), ); } + private async authorizeEntityResultAsync( + uncheckedEntityResult: Result, + ): Promise> { + if (!uncheckedEntityResult.ok) { + return uncheckedEntityResult; + } + return await asyncResult( + this.privacyPolicy.authorizeReadAsync( + this.viewerContext, + this.queryContext, + this.privacyPolicyEvaluationContext, + uncheckedEntityResult.value, + this.metricsAdapter, + ), + ); + } + + public async constructAndAuthorizeEntityAsync( + fieldsObject: Readonly, + ): Promise> { + const uncheckedEntityResult = this.tryConstructEntity(fieldsObject); + return await this.authorizeEntityResultAsync(uncheckedEntityResult); + } + /** * Validate that field values are valid according to the field's validation function. * @@ -139,15 +152,17 @@ export class EntityConstructionUtils< } private tryConstructEntities(fieldsObjects: readonly TFields[]): readonly Result[] { - return fieldsObjects.map((fieldsObject) => { - try { - return result(this.constructEntity(fieldsObject)); - } catch (e) { - if (!(e instanceof Error)) { - throw e; - } - return result(e); + return fieldsObjects.map((fieldsObject) => this.tryConstructEntity(fieldsObject)); + } + + private tryConstructEntity(fieldsObject: TFields): Result { + try { + return result(this.constructEntity(fieldsObject)); + } catch (e) { + if (!(e instanceof Error)) { + throw e; } - }); + return result(e); + } } } diff --git a/packages/entity/src/EntityDatabaseAdapter.ts b/packages/entity/src/EntityDatabaseAdapter.ts index 75cc1f25f..a1a3142e9 100644 --- a/packages/entity/src/EntityDatabaseAdapter.ts +++ b/packages/entity/src/EntityDatabaseAdapter.ts @@ -100,6 +100,53 @@ export abstract class EntityDatabaseAdapter< tableTuples: (readonly any[])[], ): Promise; + /** + * Fetch one objects where key is equal to value, null if no matching object exists. + * Returned object is not guaranteed to be deterministic. Most concrete implementations will implement this + * with a "first" or "limit 1" query. + * + * @param queryContext - query context with which to perform the fetch + * @param key - load key being queried + * @param values - load value being queried + * @returns object that matches the query for the value + */ + async fetchOneWhereAsync< + TLoadKey extends IEntityLoadKey, + TSerializedLoadValue, + TLoadValue extends IEntityLoadValue, + >( + queryContext: EntityQueryContext, + key: TLoadKey, + value: TLoadValue, + ): Promise | null> { + const keyDatabaseColumns = key.getDatabaseColumns(this.entityConfiguration); + const valueDatabaseValue = key.getDatabaseValues(value); + + const result = await this.fetchOneWhereInternalAsync( + queryContext.getQueryInterface(), + this.entityConfiguration.tableName, + keyDatabaseColumns, + valueDatabaseValue, + ); + + if (!result) { + return null; + } + + return transformDatabaseObjectToFields( + this.entityConfiguration, + this.fieldTransformerMap, + result, + ); + } + + protected abstract fetchOneWhereInternalAsync( + queryInterface: any, + tableName: string, + tableColumns: readonly string[], + tableTuple: readonly any[], + ): Promise; + /** * Insert an object. * diff --git a/packages/entity/src/__tests__/EnforcingEntityLoader-test.ts b/packages/entity/src/__tests__/EnforcingEntityLoader-test.ts index 03411db55..fd39715a1 100644 --- a/packages/entity/src/__tests__/EnforcingEntityLoader-test.ts +++ b/packages/entity/src/__tests__/EnforcingEntityLoader-test.ts @@ -447,6 +447,8 @@ describe(EnforcingEntityLoader, () => { // ensure known differences still exist for sanity check const knownLoaderOnlyDifferences = [ + 'loadOneByFieldEqualingAsync', // private method used in EntityPrivacyUtils that is not intended to be part of the public API of AuthorizationResultBasedEntityLoader, and has no equivalent in EnforcingEntityLoader + 'validateFieldAndValueAndConvertToHolders', 'validateFieldAndValuesAndConvertToHolders', 'validateCompositeFieldAndValuesAndConvertToHolders', 'constructAndAuthorizeEntitiesFromCompositeFieldValueHolderMapAsync', diff --git a/packages/entity/src/__tests__/EntityDatabaseAdapter-test.ts b/packages/entity/src/__tests__/EntityDatabaseAdapter-test.ts index 622d373ba..0df0815aa 100644 --- a/packages/entity/src/__tests__/EntityDatabaseAdapter-test.ts +++ b/packages/entity/src/__tests__/EntityDatabaseAdapter-test.ts @@ -17,23 +17,27 @@ import { TestFields, testEntityConfiguration } from '../utils/__testfixtures__/T class TestEntityDatabaseAdapter extends EntityDatabaseAdapter { private readonly fetchResults: object[]; + private readonly fetchOneResult: object | null; private readonly insertResults: object[]; private readonly updateResults: object[]; private readonly deleteCount: number; constructor({ fetchResults = [], + fetchOneResult = null, insertResults = [], updateResults = [], deleteCount = 0, }: { fetchResults?: object[]; + fetchOneResult?: object | null; insertResults?: object[]; updateResults?: object[]; deleteCount?: number; }) { super(testEntityConfiguration); this.fetchResults = fetchResults; + this.fetchOneResult = fetchOneResult; this.insertResults = insertResults; this.updateResults = updateResults; this.deleteCount = deleteCount; @@ -52,6 +56,15 @@ class TestEntityDatabaseAdapter extends EntityDatabaseAdapter { + return this.fetchOneResult; + } + protected async insertInternalAsync( _queryInterface: any, _tableName: string, diff --git a/packages/entity/src/index.ts b/packages/entity/src/index.ts index 00d472641..172ab82b5 100644 --- a/packages/entity/src/index.ts +++ b/packages/entity/src/index.ts @@ -76,6 +76,7 @@ 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/EntityDataManager.ts b/packages/entity/src/internal/EntityDataManager.ts index 8d7b75e95..f6273c1bf 100644 --- a/packages/entity/src/internal/EntityDataManager.ts +++ b/packages/entity/src/internal/EntityDataManager.ts @@ -11,7 +11,10 @@ import { EntityQueryContextProvider } from '../EntityQueryContextProvider'; import { partitionErrors } from '../entityUtils'; import { IEntityLoadKey, IEntityLoadValue, LoadPair } from './EntityLoadInterfaces'; import { ReadThroughEntityCache } from './ReadThroughEntityCache'; -import { timeAndLogLoadMapEventAsync } from '../metrics/EntityMetricsUtils'; +import { + timeAndLogLoadMapEventAsync, + timeAndLogLoadOneEventAsync, +} from '../metrics/EntityMetricsUtils'; import { EntityMetricsLoadType, IEntityMetricsAdapter, @@ -243,6 +246,34 @@ export class EntityDataManager< return mapToReturn; } + /** + * Load one object matching load key and load value if at least one matching object exists. + * Returned object is not guaranteed to be deterministic. + * + * Used when evaluating EntityEdgeDeletionAuthorizationInferenceBehavior.ONE_IMPLIES_ALL. + * + * @param queryContext - query context in which to perform the load + * @param key - load key being queried + * @param values - load value being queried for the key + * @returns at most one that match the query for that load value + */ + public async loadOneEqualingAsync< + TLoadKey extends IEntityLoadKey, + TSerializedLoadValue, + TLoadValue extends IEntityLoadValue, + >( + queryContext: EntityQueryContext, + key: TLoadKey, + value: TLoadValue, + ): Promise | null> { + return await timeAndLogLoadOneEventAsync( + this.metricsAdapter, + EntityMetricsLoadType.LOAD_ONE, + this.entityClassName, + queryContext, + )(this.databaseAdapter.fetchOneWhereAsync(queryContext, key, value)); + } + private async invalidateOneAsync< TLoadKey extends IEntityLoadKey, TSerializedLoadValue, diff --git a/packages/entity/src/metrics/EntityMetricsUtils.ts b/packages/entity/src/metrics/EntityMetricsUtils.ts index 4a5cbe392..6bd7a4bee 100644 --- a/packages/entity/src/metrics/EntityMetricsUtils.ts +++ b/packages/entity/src/metrics/EntityMetricsUtils.ts @@ -30,6 +30,29 @@ export const timeAndLogLoadEventAsync = return result; }; +export const timeAndLogLoadOneEventAsync = + ( + metricsAdapter: IEntityMetricsAdapter, + loadType: EntityMetricsLoadType, + entityClassName: string, + queryContext: EntityQueryContext, + ) => + async (promise: Promise | null>) => { + const startTime = Date.now(); + const result = await promise; + const endTime = Date.now(); + + metricsAdapter.logDataManagerLoadEvent({ + type: loadType, + isInTransaction: queryContext.isInTransaction(), + entityClassName, + duration: endTime - startTime, + count: result ? 1 : 0, + }); + + return result; + }; + export const timeAndLogLoadMapEventAsync = ( metricsAdapter: IEntityMetricsAdapter, diff --git a/packages/entity/src/metrics/IEntityMetricsAdapter.ts b/packages/entity/src/metrics/IEntityMetricsAdapter.ts index 4d06bb079..a75b440a7 100644 --- a/packages/entity/src/metrics/IEntityMetricsAdapter.ts +++ b/packages/entity/src/metrics/IEntityMetricsAdapter.ts @@ -9,6 +9,7 @@ export enum EntityMetricsLoadType { LOAD_MANY_EQUALITY_CONJUNCTION, LOAD_MANY_RAW, LOAD_MANY_SQL, + LOAD_ONE, } /** diff --git a/packages/entity-database-adapter-knex/src/utils/EntityPrivacyUtils.ts b/packages/entity/src/utils/EntityPrivacyUtils.ts similarity index 95% rename from packages/entity-database-adapter-knex/src/utils/EntityPrivacyUtils.ts rename to packages/entity/src/utils/EntityPrivacyUtils.ts index 27cdf686a..dfcfde9a5 100644 --- a/packages/entity-database-adapter-knex/src/utils/EntityPrivacyUtils.ts +++ b/packages/entity/src/utils/EntityPrivacyUtils.ts @@ -1,18 +1,16 @@ +import { Result, asyncResult } from '@expo/results'; + +import { Entity, IEntityClass } from '../Entity'; import { - Entity, - IEntityClass, EntityEdgeDeletionAuthorizationInferenceBehavior, EntityEdgeDeletionBehavior, - EntityPrivacyPolicy, - EntityPrivacyPolicyEvaluationContext, - EntityQueryContext, - ReadonlyEntity, - ViewerContext, - failedResults, - partitionArray, - EntityNotAuthorizedError, -} from '@expo/entity'; -import { Result, asyncResult } from '@expo/results'; +} 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'; export type EntityPrivacyEvaluationResultSuccess = { allowed: true; @@ -361,10 +359,6 @@ async function canViewerDeleteInternalAsync< previousValue: null, cascadingDeleteCause: newCascadingDeleteCause, }); - const knexLoader = entityCompanion.getKnexLoaderFactory().forLoad(queryContext, { - previousValue: null, - cascadingDeleteCause: newCascadingDeleteCause, - }); for (const [fieldName, fieldDefinition] of configurationForInboundEdge.schema) { const association = fieldDefinition.association; @@ -388,18 +382,12 @@ async function canViewerDeleteInternalAsync< edgeDeletionPermissionInferenceBehavior === EntityEdgeDeletionAuthorizationInferenceBehavior.ONE_IMPLIES_ALL ) { - const singleEntityResultToTestForInboundEdge = - await knexLoader.loadFirstByFieldEqualityConjunctionAsync( - [ - { - fieldName, - fieldValue: association.associatedEntityLookupByField - ? sourceEntity.getField(association.associatedEntityLookupByField as any) - : sourceEntity.getID(), - }, - ], - { orderBy: [] }, - ); + const singleEntityResultToTestForInboundEdge = await loader['loadOneByFieldEqualingAsync']( + fieldName, + association.associatedEntityLookupByField + ? sourceEntity.getField(association.associatedEntityLookupByField as any) + : sourceEntity.getID(), + ); entityResultsToCheckForInboundEdge = singleEntityResultToTestForInboundEdge ? [singleEntityResultToTestForInboundEdge] : []; diff --git a/packages/entity/src/utils/__testfixtures__/StubDatabaseAdapter.ts b/packages/entity/src/utils/__testfixtures__/StubDatabaseAdapter.ts index c9ec13a9d..e7759e5ac 100644 --- a/packages/entity/src/utils/__testfixtures__/StubDatabaseAdapter.ts +++ b/packages/entity/src/utils/__testfixtures__/StubDatabaseAdapter.ts @@ -78,6 +78,21 @@ export class StubDatabaseAdapter< return [...results]; } + protected async fetchOneWhereInternalAsync( + queryInterface: any, + tableName: string, + tableColumns: readonly string[], + tableTuple: readonly any[], + ): Promise { + const results = await this.fetchManyWhereInternalAsync( + queryInterface, + tableName, + tableColumns, + [tableTuple], + ); + return results[0] ?? null; + } + private generateRandomID(): any { const idSchemaField = this.entityConfiguration2.schema.get(this.entityConfiguration2.idField); invariant( diff --git a/packages/entity-database-adapter-knex/src/utils/__tests__/EntityPrivacyUtils-test.ts b/packages/entity/src/utils/__tests__/EntityPrivacyUtils-test.ts similarity index 94% rename from packages/entity-database-adapter-knex/src/utils/__tests__/EntityPrivacyUtils-test.ts rename to packages/entity/src/utils/__tests__/EntityPrivacyUtils-test.ts index 45e9d1f4c..2f3829431 100644 --- a/packages/entity-database-adapter-knex/src/utils/__tests__/EntityPrivacyUtils-test.ts +++ b/packages/entity/src/utils/__tests__/EntityPrivacyUtils-test.ts @@ -1,23 +1,22 @@ +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, - 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'; +} 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'; import { canViewerDeleteAsync, canViewerUpdateAsync, @@ -26,6 +25,7 @@ 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 = createUnitTestPostgresEntityCompanionProvider(); + const companionProvider = createUnitTestEntityCompanionProvider(); 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 = createUnitTestPostgresEntityCompanionProvider(); + const companionProvider = createUnitTestEntityCompanionProvider(); 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 = createUnitTestPostgresEntityCompanionProvider(); + const companionProvider = createUnitTestEntityCompanionProvider(); 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 = createUnitTestPostgresEntityCompanionProvider(); + const companionProvider = createUnitTestEntityCompanionProvider(); 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 = createUnitTestPostgresEntityCompanionProvider(); + const companionProvider = createUnitTestEntityCompanionProvider(); 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 = createUnitTestPostgresEntityCompanionProvider(); + const companionProvider = createUnitTestEntityCompanionProvider(); 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 = createUnitTestPostgresEntityCompanionProvider(); + const companionProvider = createUnitTestEntityCompanionProvider(); 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 = createUnitTestPostgresEntityCompanionProvider(); + const companionProvider = createUnitTestEntityCompanionProvider(); 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 = createUnitTestPostgresEntityCompanionProvider(); + const companionProvider = createUnitTestEntityCompanionProvider(); 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 = createUnitTestPostgresEntityCompanionProvider(); + const companionProvider = createUnitTestEntityCompanionProvider(); 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 = createUnitTestPostgresEntityCompanionProvider(); + const companionProvider = createUnitTestEntityCompanionProvider(); 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 = createUnitTestPostgresEntityCompanionProvider(); + const companionProvider = createUnitTestEntityCompanionProvider(); 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 = createUnitTestPostgresEntityCompanionProvider(); + const companionProvider = createUnitTestEntityCompanionProvider(); 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 = createUnitTestPostgresEntityCompanionProvider(); + const companionProvider = createUnitTestEntityCompanionProvider(); const viewerContext = new ViewerContext(companionProvider); const testEntity = await ParentEntity.creator(viewerContext).createAsync(); diff --git a/packages/entity-database-adapter-knex/src/utils/__tests__/canViewerDeleteAsync-edgeDeletionPermissionInferenceBehavior-test.ts b/packages/entity/src/utils/__tests__/canViewerDeleteAsync-edgeDeletionPermissionInferenceBehavior-test.ts similarity index 90% rename from packages/entity-database-adapter-knex/src/utils/__tests__/canViewerDeleteAsync-edgeDeletionPermissionInferenceBehavior-test.ts rename to packages/entity/src/utils/__tests__/canViewerDeleteAsync-edgeDeletionPermissionInferenceBehavior-test.ts index c27aedf11..ee65b92b0 100644 --- a/packages/entity-database-adapter-knex/src/utils/__tests__/canViewerDeleteAsync-edgeDeletionPermissionInferenceBehavior-test.ts +++ b/packages/entity/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, - UUIDField, - EntityPrivacyPolicy, - ReadonlyEntity, - ViewerContext, - AlwaysAllowPrivacyPolicyRule, - AlwaysDenyPrivacyPolicyRule, -} from '@expo/entity'; -import { describe, expect, it, jest } from '@jest/globals'; - -import { createUnitTestPostgresEntityCompanionProvider } from '../../__tests__/fixtures/createUnitTestPostgresEntityCompanionProvider'; +} 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'; import { canViewerDeleteAsync } from '../EntityPrivacyUtils'; +import { createUnitTestEntityCompanionProvider } from '../__testfixtures__/createUnitTestEntityCompanionProvider'; describe(canViewerDeleteAsync, () => { describe('edgeDeletionPermissionInferenceBehavior', () => { it('optimizes when EntityEdgeDeletionPermissionInferenceBehavior.ONE_IMPLIES_ALL', async () => { - const companionProvider = createUnitTestPostgresEntityCompanionProvider(); + const companionProvider = createUnitTestEntityCompanionProvider(); const viewerContext = new ViewerContext(companionProvider); // create root @@ -61,7 +61,7 @@ describe(canViewerDeleteAsync, () => { }); it('does not optimize when undefined', async () => { - const companionProvider = createUnitTestPostgresEntityCompanionProvider(); + const companionProvider = createUnitTestEntityCompanionProvider(); const viewerContext = new ViewerContext(companionProvider); // create root