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 090f926c8..cf4382e2f 100644 --- a/packages/entity-database-adapter-knex-testing-utils/src/StubPostgresDatabaseAdapter.ts +++ b/packages/entity-database-adapter-knex-testing-utils/src/StubPostgresDatabaseAdapter.ts @@ -227,6 +227,56 @@ export class StubPostgresDatabaseAdapter< } } + protected async batchInsertInternalAsync( + _queryInterface: any, + tableName: string, + objects: readonly object[], + ): Promise { + const objectCollection = this.getObjectCollectionForTable(tableName); + + const idField = getDatabaseFieldForEntityField( + this.entityConfiguration2, + this.entityConfiguration2.idField, + ); + const insertedObjects: object[] = []; + for (const object of objects) { + const objectToInsert = { + [idField]: this.generateRandomID(), + ...object, + }; + objectCollection.push(objectToInsert); + insertedObjects.push(objectToInsert); + } + return insertedObjects; + } + + protected async batchUpdateInternalAsync( + _queryInterface: any, + tableName: string, + tableIdField: string, + ids: readonly any[], + object: object, + ): Promise { + if (Object.keys(object).length === 0) { + throw new Error(`Empty batch update (${tableIdField} IN (${ids.join(', ')}))`); + } + + const objectCollection = this.getObjectCollectionForTable(tableName); + const updatedObjects: object[] = []; + + for (const id of ids) { + const objectIndex = objectCollection.findIndex((obj) => obj[tableIdField] === id); + if (objectIndex >= 0) { + objectCollection[objectIndex] = { + ...objectCollection[objectIndex], + ...object, + }; + updatedObjects.push(objectCollection[objectIndex]); + } + } + return updatedObjects; + } + protected async insertInternalAsync( _queryInterface: any, tableName: string, @@ -277,6 +327,25 @@ export class StubPostgresDatabaseAdapter< return [objectCollection[objectIndex]]; } + protected async batchDeleteInternalAsync( + _queryInterface: any, + tableName: string, + tableIdField: string, + ids: readonly any[], + ): Promise { + const objectCollection = this.getObjectCollectionForTable(tableName); + let count = 0; + + for (const id of ids) { + const objectIndex = objectCollection.findIndex((obj) => obj[tableIdField] === id); + if (objectIndex >= 0) { + objectCollection.splice(objectIndex, 1); + count++; + } + } + return count; + } + protected async deleteInternalAsync( _queryInterface: any, tableName: string, diff --git a/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts b/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts index d73483390..0cef88841 100644 --- a/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts +++ b/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts @@ -250,6 +250,35 @@ export class PostgresEntityDatabaseAdapter< return await wrapNativePostgresCallAsync(() => query); } + protected async batchInsertInternalAsync( + queryInterface: Knex, + tableName: string, + objects: readonly object[], + ): Promise { + return await wrapNativePostgresCallAsync(() => + queryInterface + .insert([...objects]) + .into(tableName) + .returning('*'), + ); + } + + protected async batchUpdateInternalAsync( + queryInterface: Knex, + tableName: string, + tableIdField: string, + ids: readonly any[], + object: object, + ): Promise { + return await wrapNativePostgresCallAsync(() => + queryInterface + .update(object) + .into(tableName) + .whereIn(tableIdField, [...ids]) + .returning('*'), + ); + } + protected async insertInternalAsync( queryInterface: Knex, tableName: string, @@ -272,6 +301,20 @@ export class PostgresEntityDatabaseAdapter< ); } + protected async batchDeleteInternalAsync( + queryInterface: Knex, + tableName: string, + tableIdField: string, + ids: readonly any[], + ): Promise { + return await wrapNativePostgresCallAsync(() => + queryInterface + .into(tableName) + .whereIn(tableIdField, [...ids]) + .del(), + ); + } + protected async deleteInternalAsync( queryInterface: Knex, tableName: string, 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 62ff0487e..d67583c17 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,4 +1,5 @@ import { + EntityDatabaseAdapterBatchUpdateMismatchResultError, EntityDatabaseAdapterEmptyUpdateResultError, TransactionIsolationLevel, ViewerContext, @@ -12,8 +13,14 @@ import { setTimeout } from 'timers/promises'; import { OrderByOrdering } from '../BasePostgresEntityDatabaseAdapter'; import { PaginationStrategy } from '../PaginationStrategy'; +import { PostgresEntityDatabaseAdapter } from '../PostgresEntityDatabaseAdapter'; +import { PostgresEntityQueryContextProvider } from '../PostgresEntityQueryContextProvider'; import { raw, sql, SQLFragment, SQLFragmentHelpers } from '../SQLOperator'; -import { PostgresTestEntity } from '../__testfixtures__/PostgresTestEntity'; +import { + PostgresTestEntity, + PostgresTestEntityFields, + postgresTestEntityConfiguration, +} from '../__testfixtures__/PostgresTestEntity'; import { PostgresTriggerTestEntity } from '../__testfixtures__/PostgresTriggerTestEntity'; import { PostgresValidatorTestEntity } from '../__testfixtures__/PostgresValidatorTestEntity'; import { createKnexIntegrationTestEntityCompanionProvider } from '../__testfixtures__/createKnexIntegrationTestEntityCompanionProvider'; @@ -2824,4 +2831,282 @@ describe('postgres entity integration', () => { }); }); }); + + describe('batch operations', () => { + let adapter: PostgresEntityDatabaseAdapter; + let queryContextProvider: PostgresEntityQueryContextProvider; + + beforeAll(() => { + adapter = new PostgresEntityDatabaseAdapter(postgresTestEntityConfiguration); + queryContextProvider = new PostgresEntityQueryContextProvider(knexInstance); + }); + + describe('batchInsertAsync', () => { + it('inserts multiple rows and returns all with correct fields', async () => { + const queryContext = queryContextProvider.getQueryContext(); + const results = await adapter.batchInsertAsync(queryContext, [ + { name: 'batch1', hasACat: true, hasADog: false }, + { name: 'batch2', hasACat: false, hasADog: true }, + { name: 'batch3', hasACat: true, hasADog: true }, + ]); + + expect(results).toHaveLength(3); + expect(results[0]!.name).toBe('batch1'); + expect(results[0]!.hasACat).toBe(true); + expect(results[0]!.hasADog).toBe(false); + expect(results[0]!.id).toBeTruthy(); + expect(results[1]!.name).toBe('batch2'); + expect(results[2]!.name).toBe('batch3'); + + // Verify all exist in DB + const vc = new ViewerContext( + createKnexIntegrationTestEntityCompanionProvider(knexInstance), + ); + for (const result of results) { + const loaded = await PostgresTestEntity.loader(vc).loadByIDAsync(result.id); + expect(loaded.getField('name')).toBe(result.name); + } + }); + + it('fails the whole batch on unique constraint violation', async () => { + const queryContext = queryContextProvider.getQueryContext(); + + // Insert the first row + await adapter.batchInsertAsync(queryContext, [{ name: 'unique-test' }]); + + // Now try a batch insert where the second row has the same generated UUID + // We'll test this by inserting rows with the same explicit ID + const id = '00000000-0000-0000-0000-000000000001'; + await adapter.batchInsertAsync(queryContext, [{ id, name: 'first' }]); + + await expect( + adapter.batchInsertAsync(queryContext, [{ id, name: 'duplicate' }]), + ).rejects.toThrow(); + }); + + it('handles JSON array field transformation in batch', async () => { + const queryContext = queryContextProvider.getQueryContext(); + const results = await adapter.batchInsertAsync(queryContext, [ + { name: 'json-batch1', jsonArrayField: ['a', 'b', 'c'] }, + { name: 'json-batch2', jsonArrayField: ['x', 'y'] }, + ]); + + expect(results).toHaveLength(2); + expect(results[0]!.jsonArrayField).toEqual(['a', 'b', 'c']); + expect(results[1]!.jsonArrayField).toEqual(['x', 'y']); + }); + }); + + describe('batchUpdateAsync', () => { + it('updates multiple rows and returns all with correct fields', async () => { + const queryContext = queryContextProvider.getQueryContext(); + + // Insert rows first + const inserted = await adapter.batchInsertAsync(queryContext, [ + { name: 'update1', hasACat: false }, + { name: 'update2', hasACat: false }, + { name: 'update3', hasACat: false }, + ]); + + const ids = inserted.map((r) => r.id); + const updated = await adapter.batchUpdateAsync(queryContext, 'id', ids, { + hasACat: true, + }); + + expect(updated).toHaveLength(3); + for (const result of updated) { + expect(result.hasACat).toBe(true); + } + }); + + it('throws mismatch error when updating with a nonexistent ID', async () => { + const queryContext = queryContextProvider.getQueryContext(); + + const inserted = await adapter.batchInsertAsync(queryContext, [{ name: 'exists' }]); + + await expect( + adapter.batchUpdateAsync( + queryContext, + 'id', + [inserted[0]!.id, '00000000-0000-0000-0000-000000000099'], + { name: 'updated' }, + ), + ).rejects.toThrow(EntityDatabaseAdapterBatchUpdateMismatchResultError); + }); + + it('returns results in input ID order', async () => { + const queryContext = queryContextProvider.getQueryContext(); + + const inserted = await adapter.batchInsertAsync(queryContext, [ + { name: 'order-a' }, + { name: 'order-b' }, + { name: 'order-c' }, + ]); + + // Request update in reverse order + const reversedIds = [inserted[2]!.id, inserted[1]!.id, inserted[0]!.id]; + const updated = await adapter.batchUpdateAsync(queryContext, 'id', reversedIds, { + hasADog: true, + }); + + expect(updated).toHaveLength(3); + expect(updated[0]!.id).toBe(inserted[2]!.id); + expect(updated[1]!.id).toBe(inserted[1]!.id); + expect(updated[2]!.id).toBe(inserted[0]!.id); + }); + }); + }); + + describe('batch entity operations', () => { + it('batch creates multiple entities via entity-level API', async () => { + const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + const entities = await PostgresTestEntity.batchCreator(vc, [ + { name: 'batch-a', hasACat: true }, + { name: 'batch-b', hasACat: false }, + { name: 'batch-c', hasADog: true }, + ]).createAsync(); + + expect(entities).toHaveLength(3); + expect(entities[0]!.getField('name')).toBe('batch-a'); + expect(entities[0]!.getField('hasACat')).toBe(true); + expect(entities[1]!.getField('name')).toBe('batch-b'); + expect(entities[1]!.getField('hasACat')).toBe(false); + expect(entities[2]!.getField('name')).toBe('batch-c'); + expect(entities[2]!.getField('hasADog')).toBe(true); + + // Verify all are loadable independently + for (const entity of entities) { + const loaded = await PostgresTestEntity.loader(vc).loadByIDAsync(entity.getID()); + expect(loaded.getID()).toBe(entity.getID()); + } + }); + + it('batch creates with empty array returns empty', async () => { + const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + const entities = await PostgresTestEntity.batchCreator(vc, []).createAsync(); + expect(entities).toHaveLength(0); + }); + + it('batch updates multiple entities with same field changes', async () => { + const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + // Create entities first + const entity1 = await PostgresTestEntity.creator(vc) + .setField('name', 'update-a') + .setField('hasACat', false) + .createAsync(); + const entity2 = await PostgresTestEntity.creator(vc) + .setField('name', 'update-b') + .setField('hasACat', false) + .createAsync(); + + // Batch update + const updatedEntities = await PostgresTestEntity.batchUpdater([entity1, entity2]) + .setField('hasACat', true) + .updateAsync(); + + expect(updatedEntities).toHaveLength(2); + expect(updatedEntities[0]!.getField('hasACat')).toBe(true); + expect(updatedEntities[1]!.getField('hasACat')).toBe(true); + // Verify names unchanged + expect(updatedEntities[0]!.getField('name')).toBe('update-a'); + expect(updatedEntities[1]!.getField('name')).toBe('update-b'); + // Verify ordering matches input + expect(updatedEntities[0]!.getID()).toBe(entity1.getID()); + expect(updatedEntities[1]!.getID()).toBe(entity2.getID()); + + // Verify via reload + const reloaded1 = await PostgresTestEntity.loader(vc).loadByIDAsync(entity1.getID()); + const reloaded2 = await PostgresTestEntity.loader(vc).loadByIDAsync(entity2.getID()); + expect(reloaded1.getField('hasACat')).toBe(true); + expect(reloaded2.getField('hasACat')).toBe(true); + }); + + it('batch create with authorization results returns result', async () => { + const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + const result = await PostgresTestEntity.batchCreatorWithAuthorizationResults(vc, [ + { name: 'result-a' }, + { name: 'result-b' }, + ]).createAsync(); + + expect(result.ok).toBe(true); + const entities = result.enforceValue(); + expect(entities).toHaveLength(2); + expect(entities[0]!.getField('name')).toBe('result-a'); + expect(entities[1]!.getField('name')).toBe('result-b'); + }); + + it('batch update with authorization results returns result', async () => { + const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + const entity1 = await PostgresTestEntity.creator(vc) + .setField('name', 'auth-update-a') + .createAsync(); + const entity2 = await PostgresTestEntity.creator(vc) + .setField('name', 'auth-update-b') + .createAsync(); + + const result = await PostgresTestEntity.batchUpdaterWithAuthorizationResults([ + entity1, + entity2, + ]) + .setField('hasADog', true) + .updateAsync(); + + expect(result.ok).toBe(true); + const entities = result.enforceValue(); + expect(entities).toHaveLength(2); + expect(entities[0]!.getField('hasADog')).toBe(true); + expect(entities[1]!.getField('hasADog')).toBe(true); + }); + + it('batch deletes multiple entities', async () => { + const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + const entity1 = await PostgresTestEntity.creator(vc) + .setField('name', 'delete-a') + .createAsync(); + const entity2 = await PostgresTestEntity.creator(vc) + .setField('name', 'delete-b') + .createAsync(); + const entity3 = await PostgresTestEntity.creator(vc).setField('name', 'keep-c').createAsync(); + + await PostgresTestEntity.batchDeleter([entity1, entity2]).deleteAsync(); + + // Verify deleted entities are not loadable + const loadResult1 = await PostgresTestEntity.loaderWithAuthorizationResults(vc).loadByIDAsync( + entity1.getID(), + ); + expect(loadResult1.ok).toBe(false); + + const loadResult2 = await PostgresTestEntity.loaderWithAuthorizationResults(vc).loadByIDAsync( + entity2.getID(), + ); + expect(loadResult2.ok).toBe(false); + + // Verify non-deleted entity is still loadable + const loadedEntity3 = await PostgresTestEntity.loader(vc).loadByIDAsync(entity3.getID()); + expect(loadedEntity3.getField('name')).toBe('keep-c'); + }); + + it('batch delete with authorization results returns success result', async () => { + const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + const entity1 = await PostgresTestEntity.creator(vc) + .setField('name', 'auth-delete-a') + .createAsync(); + const entity2 = await PostgresTestEntity.creator(vc) + .setField('name', 'auth-delete-b') + .createAsync(); + + const result = await PostgresTestEntity.batchDeleterWithAuthorizationResults([ + entity1, + entity2, + ]).deleteAsync(); + + expect(result.ok).toBe(true); + }); + }); }); diff --git a/packages/entity-database-adapter-knex/src/__testfixtures__/PostgresTestEntity.ts b/packages/entity-database-adapter-knex/src/__testfixtures__/PostgresTestEntity.ts index 333f1635f..71f95fbef 100644 --- a/packages/entity-database-adapter-knex/src/__testfixtures__/PostgresTestEntity.ts +++ b/packages/entity-database-adapter-knex/src/__testfixtures__/PostgresTestEntity.ts @@ -17,7 +17,7 @@ import { Knex } from 'knex'; import { BigIntField, JSONArrayField, MaybeJSONArrayField } from '../EntityFields'; import { PostgresEntity } from '../PostgresEntity'; -type PostgresTestEntityFields = { +export type PostgresTestEntityFields = { id: string; name: string | null; hasADog: boolean | null; 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 a76645802..9b2bf813b 100644 --- a/packages/entity-database-adapter-knex/src/__tests__/BasePostgresEntityDatabaseAdapter-test.ts +++ b/packages/entity-database-adapter-knex/src/__tests__/BasePostgresEntityDatabaseAdapter-test.ts @@ -100,6 +100,24 @@ class TestEntityDatabaseAdapter extends BasePostgresEntityDatabaseAdapter< return this.fetchEqualityConditionResults; } + protected async batchInsertInternalAsync( + _queryInterface: any, + _tableName: string, + _objects: readonly object[], + ): Promise { + return []; + } + + protected async batchUpdateInternalAsync( + _queryInterface: any, + _tableName: string, + _tableIdField: string, + _ids: readonly any[], + _object: object, + ): Promise { + return []; + } + protected async insertInternalAsync( _queryInterface: any, _tableName: string, @@ -118,6 +136,15 @@ class TestEntityDatabaseAdapter extends BasePostgresEntityDatabaseAdapter< return this.updateResults; } + protected async batchDeleteInternalAsync( + _queryInterface: any, + _tableName: string, + _tableIdField: string, + _ids: readonly any[], + ): Promise { + return 0; + } + protected async deleteInternalAsync( _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 fd3beb7d9..3e2867c6d 100644 --- a/packages/entity-database-adapter-knex/src/__tests__/fixtures/StubPostgresDatabaseAdapter.ts +++ b/packages/entity-database-adapter-knex/src/__tests__/fixtures/StubPostgresDatabaseAdapter.ts @@ -228,6 +228,56 @@ export class StubPostgresDatabaseAdapter< } } + protected async batchInsertInternalAsync( + _queryInterface: any, + tableName: string, + objects: readonly object[], + ): Promise { + const objectCollection = this.getObjectCollectionForTable(tableName); + + const idField = getDatabaseFieldForEntityField( + this.entityConfiguration2, + this.entityConfiguration2.idField, + ); + const insertedObjects: object[] = []; + for (const object of objects) { + const objectToInsert = { + [idField]: this.generateRandomID(), + ...object, + }; + objectCollection.push(objectToInsert); + insertedObjects.push(objectToInsert); + } + return insertedObjects; + } + + protected async batchUpdateInternalAsync( + _queryInterface: any, + tableName: string, + tableIdField: string, + ids: readonly any[], + object: object, + ): Promise { + if (Object.keys(object).length === 0) { + throw new Error(`Empty batch update (${tableIdField} IN (${ids.join(', ')}))`); + } + + const objectCollection = this.getObjectCollectionForTable(tableName); + const updatedObjects: object[] = []; + + for (const id of ids) { + const objectIndex = objectCollection.findIndex((obj) => obj[tableIdField] === id); + if (objectIndex >= 0) { + objectCollection[objectIndex] = { + ...objectCollection[objectIndex], + ...object, + }; + updatedObjects.push(objectCollection[objectIndex]); + } + } + return updatedObjects; + } + protected async insertInternalAsync( _queryInterface: any, tableName: string, @@ -278,6 +328,25 @@ export class StubPostgresDatabaseAdapter< return [objectCollection[objectIndex]]; } + protected async batchDeleteInternalAsync( + _queryInterface: any, + tableName: string, + tableIdField: string, + ids: readonly any[], + ): Promise { + const objectCollection = this.getObjectCollectionForTable(tableName); + let count = 0; + + for (const id of ids) { + const objectIndex = objectCollection.findIndex((obj) => obj[tableIdField] === id); + if (objectIndex >= 0) { + objectCollection.splice(objectIndex, 1); + count++; + } + } + return count; + } + protected async deleteInternalAsync( _queryInterface: any, tableName: string, diff --git a/packages/entity-example/src/adapters/InMemoryDatabaseAdapter.ts b/packages/entity-example/src/adapters/InMemoryDatabaseAdapter.ts index 4cacddc32..b88dccb6b 100644 --- a/packages/entity-example/src/adapters/InMemoryDatabaseAdapter.ts +++ b/packages/entity-example/src/adapters/InMemoryDatabaseAdapter.ts @@ -62,6 +62,53 @@ class InMemoryDatabaseAdapter< return results[0] ?? null; } + protected async batchInsertInternalAsync( + _queryInterface: any, + _tableName: string, + objects: readonly object[], + ): Promise { + const configurationPrivate = this['entityConfiguration']; + const idField = getDatabaseFieldForEntityField( + configurationPrivate, + configurationPrivate.idField, + ); + const insertedObjects: object[] = []; + for (const object of objects) { + const objectToInsert = { + [idField]: uuidv4(), + ...object, + }; + dbObjects.push(objectToInsert); + insertedObjects.push(objectToInsert); + } + return insertedObjects; + } + + protected async batchUpdateInternalAsync( + _queryInterface: any, + _tableName: string, + tableIdField: string, + ids: readonly any[], + object: object, + ): Promise { + if (Object.keys(object).length === 0) { + throw new Error(`Empty batch update (${tableIdField} IN (${ids.join(', ')}))`); + } + + const updatedObjects: object[] = []; + for (const id of ids) { + const objectIndex = dbObjects.findIndex((obj) => obj[tableIdField] === id); + if (objectIndex >= 0) { + dbObjects[objectIndex] = { + ...dbObjects[objectIndex], + ...object, + }; + updatedObjects.push(dbObjects[objectIndex]); + } + } + return updatedObjects; + } + protected async insertInternalAsync( _queryInterface: any, _tableName: string, @@ -109,6 +156,24 @@ class InMemoryDatabaseAdapter< return [dbObjects[objectIndex]]; } + protected async batchDeleteInternalAsync( + _queryInterface: any, + _tableName: string, + tableIdField: string, + ids: readonly any[], + ): Promise { + let count = 0; + + for (const id of ids) { + const objectIndex = dbObjects.findIndex((obj) => obj[tableIdField] === id); + if (objectIndex >= 0) { + dbObjects.splice(objectIndex, 1); + count++; + } + } + return count; + } + protected async deleteInternalAsync( _queryInterface: any, _tableName: string, diff --git a/packages/entity-testing-utils/src/StubDatabaseAdapter.ts b/packages/entity-testing-utils/src/StubDatabaseAdapter.ts index 3a9e7a8cf..bec19b2ca 100644 --- a/packages/entity-testing-utils/src/StubDatabaseAdapter.ts +++ b/packages/entity-testing-utils/src/StubDatabaseAdapter.ts @@ -111,6 +111,56 @@ export class StubDatabaseAdapter< } } + protected async batchInsertInternalAsync( + _queryInterface: any, + tableName: string, + objects: readonly object[], + ): Promise { + const objectCollection = this.getObjectCollectionForTable(tableName); + + const idField = getDatabaseFieldForEntityField( + this.entityConfiguration2, + this.entityConfiguration2.idField, + ); + const insertedObjects: object[] = []; + for (const object of objects) { + const objectToInsert = { + [idField]: this.generateRandomID(), + ...object, + }; + objectCollection.push(objectToInsert); + insertedObjects.push(objectToInsert); + } + return insertedObjects; + } + + protected async batchUpdateInternalAsync( + _queryInterface: any, + tableName: string, + tableIdField: string, + ids: readonly any[], + object: object, + ): Promise { + if (Object.keys(object).length === 0) { + throw new Error(`Empty batch update (${tableIdField} IN (${ids.join(', ')}))`); + } + + const objectCollection = this.getObjectCollectionForTable(tableName); + const updatedObjects: object[] = []; + + for (const id of ids) { + const objectIndex = objectCollection.findIndex((obj) => obj[tableIdField] === id); + if (objectIndex >= 0) { + objectCollection[objectIndex] = { + ...objectCollection[objectIndex], + ...object, + }; + updatedObjects.push(objectCollection[objectIndex]); + } + } + return updatedObjects; + } + protected async insertInternalAsync( _queryInterface: any, tableName: string, @@ -161,6 +211,25 @@ export class StubDatabaseAdapter< return [objectCollection[objectIndex]]; } + protected async batchDeleteInternalAsync( + _queryInterface: any, + tableName: string, + tableIdField: string, + ids: readonly any[], + ): Promise { + const objectCollection = this.getObjectCollectionForTable(tableName); + let count = 0; + + for (const id of ids) { + const objectIndex = objectCollection.findIndex((obj) => obj[tableIdField] === id); + if (objectIndex >= 0) { + objectCollection.splice(objectIndex, 1); + count++; + } + } + return count; + } + protected async deleteInternalAsync( _queryInterface: any, tableName: string, diff --git a/packages/entity/src/AuthorizationResultBasedEntityMutator.ts b/packages/entity/src/AuthorizationResultBasedEntityMutator.ts index 9190e056d..15661ac8d 100644 --- a/packages/entity/src/AuthorizationResultBasedEntityMutator.ts +++ b/packages/entity/src/AuthorizationResultBasedEntityMutator.ts @@ -31,6 +31,30 @@ import { timeAndLogMutationEventAsync } from './metrics/EntityMetricsUtils'; import { EntityMetricsMutationType, IEntityMetricsAdapter } from './metrics/IEntityMetricsAdapter'; import { mapMapAsync } from './utils/collections/maps'; +type EntityLoaderForBatch< + TFields extends Record, + TIDField extends keyof NonNullable>, + TViewerContext extends ViewerContext, + TEntity extends Entity, + TPrivacyPolicy extends EntityPrivacyPolicy< + TFields, + TIDField, + TViewerContext, + TEntity, + TSelectedFields + >, + TSelectedFields extends keyof TFields, +> = ReturnType< + EntityLoaderFactory< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >['forLoad'] +>; + /** * Base class for entity mutators. Mutators are builder-like class instances that are * responsible for creating, updating, and deleting entities, and for calling out to @@ -240,6 +264,151 @@ export abstract class AuthorizationResultBasedBaseMutator< ); } + /** + * Finds all entities referencing the specified entity and either deletes them, nullifies + * their references to the specified entity, or invalidates the cache depending on the + * OnDeleteBehavior of the field referencing the specified entity. + * + * @remarks + * This works by doing reverse fan-out queries: + * 1. Load all entity configurations of entity types that reference this type of entity + * 2. For each entity configuration, find all fields that contain edges to this type of entity + * 3. For each edge field, load all entities with an edge from target entity to this entity via that field + * 4. Perform desired OnDeleteBehavior for entities + * + * @param entity - entity to find all references to + * @param cascadingDeleteCause - cascading delete info to pass down + * @param queryContext - query context for the transaction + * @param processedEntityIdentifiers - set tracking already-processed entities to prevent cycles + */ + protected async processEntityDeletionForInboundEdgesAsync( + entity: TEntity, + cascadingDeleteCause: EntityCascadingDeletionInfo | null, + queryContext: EntityTransactionalQueryContext, + processedEntityIdentifiers: Set, + ): Promise { + // prevent infinite reference cycles by keeping track of entities already processed + if (processedEntityIdentifiers.has(entity.getUniqueIdentifier())) { + return; + } + processedEntityIdentifiers.add(entity.getUniqueIdentifier()); + + const companionDefinition = this.companionProvider.getCompanionForEntity( + entity.constructor as IEntityClass< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >, + ).entityCompanionDefinition; + const entityConfiguration = companionDefinition.entityConfiguration; + const inboundEdges = entityConfiguration.inboundEdges; + + const newCascadingDeleteCause = { + entity, + cascadingDeleteCause, + }; + + await Promise.all( + inboundEdges.map(async (entityClass) => { + const loaderFactory = entity + .getViewerContext() + .getViewerScopedEntityCompanionForClass(entityClass) + .getLoaderFactory(); + const mutatorFactory = entity + .getViewerContext() + .getViewerScopedEntityCompanionForClass(entityClass) + .getMutatorFactory(); + + return await mapMapAsync( + this.companionProvider.getCompanionForEntity(entityClass).entityCompanionDefinition + .entityConfiguration.schema, + async (fieldDefinition, fieldName) => { + const association = fieldDefinition.association; + if (!association) { + return; + } + + const associatedConfiguration = this.companionProvider.getCompanionForEntity( + association.associatedEntityClass, + ).entityCompanionDefinition.entityConfiguration; + if (associatedConfiguration !== entityConfiguration) { + return; + } + + const inboundReferenceEntities = await enforceResultsAsync( + loaderFactory + .forLoad(queryContext, { + previousValue: null, + cascadingDeleteCause: newCascadingDeleteCause, + }) + .loadManyByFieldEqualingAsync( + fieldName, + association.associatedEntityLookupByField + ? entity.getField(association.associatedEntityLookupByField as any) + : entity.getID(), + ), + ); + + switch (association.edgeDeletionBehavior) { + case EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY: { + await Promise.all( + inboundReferenceEntities.map((inboundReferenceEntity) => + enforceAsyncResult( + mutatorFactory + .forDelete(inboundReferenceEntity, queryContext, newCascadingDeleteCause) + ['deleteInTransactionAsync'](processedEntityIdentifiers, true), + ), + ), + ); + break; + } + case EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY: { + await Promise.all( + inboundReferenceEntities.map((inboundReferenceEntity) => + enforceAsyncResult( + mutatorFactory + .forUpdate(inboundReferenceEntity, queryContext, newCascadingDeleteCause) + .setField(fieldName, null) + ['updateInTransactionAsync'](/* skipDatabaseUpdate */ true), + ), + ), + ); + break; + } + case EntityEdgeDeletionBehavior.SET_NULL: { + await Promise.all( + inboundReferenceEntities.map((inboundReferenceEntity) => + enforceAsyncResult( + mutatorFactory + .forUpdate(inboundReferenceEntity, queryContext, newCascadingDeleteCause) + .setField(fieldName, null) + ['updateInTransactionAsync'](/* skipDatabaseUpdate */ false), + ), + ), + ); + break; + } + case EntityEdgeDeletionBehavior.CASCADE_DELETE: { + await Promise.all( + inboundReferenceEntities.map((inboundReferenceEntity) => + enforceAsyncResult( + mutatorFactory + .forDelete(inboundReferenceEntity, queryContext, newCascadingDeleteCause) + ['deleteInTransactionAsync'](processedEntityIdentifiers, false), + ), + ), + ); + } + } + }, + ); + }), + ); + } + protected async executeNonTransactionalMutationTriggersAsync( triggers: | EntityNonTransactionalMutationTrigger< @@ -810,6 +979,7 @@ export class AuthorizationResultBasedDeleteMutator< await this.processEntityDeletionForInboundEdgesAsync( this.entity, + this.cascadingDeleteCause, queryContext, processedEntityIdentifiersFromTransitiveDeletions, ); @@ -904,151 +1074,790 @@ export class AuthorizationResultBasedDeleteMutator< return result(); } +} + +/** + * Mutator for batch creating multiple new entities in a single transaction with a single + * multi-row INSERT statement. Runs the full entity mutation pipeline (authorization, + * validation, triggers, cache invalidation) per-item. + */ +export class AuthorizationResultBasedBatchCreateMutator< + TFields extends Record, + TIDField extends keyof NonNullable>, + TViewerContext extends ViewerContext, + TEntity extends Entity, + TPrivacyPolicy extends EntityPrivacyPolicy< + TFields, + TIDField, + TViewerContext, + TEntity, + TSelectedFields + >, + TSelectedFields extends keyof TFields, +> extends AuthorizationResultBasedBaseMutator< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields +> { + constructor( + companionProvider: EntityCompanionProvider, + viewerContext: TViewerContext, + queryContext: EntityQueryContext, + entityConfiguration: EntityConfiguration, + entityClass: IEntityClass< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >, + privacyPolicy: TPrivacyPolicy, + mutationValidators: EntityMutationValidatorConfiguration< + TFields, + TIDField, + TViewerContext, + TEntity, + TSelectedFields + >, + mutationTriggers: EntityMutationTriggerConfiguration< + TFields, + TIDField, + TViewerContext, + TEntity, + TSelectedFields + >, + entityLoaderFactory: EntityLoaderFactory< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >, + databaseAdapter: EntityDatabaseAdapter, + metricsAdapter: IEntityMetricsAdapter, + private readonly fieldObjects: readonly Readonly>[], + ) { + super( + companionProvider, + viewerContext, + queryContext, + entityConfiguration, + entityClass, + privacyPolicy, + mutationValidators, + mutationTriggers, + entityLoaderFactory, + databaseAdapter, + metricsAdapter, + ); + } /** - * Finds all entities referencing the specified entity and either deletes them, nullifies - * their references to the specified entity, or invalidates the cache depending on the - * OnDeleteBehavior of the field referencing the specified entity. - * - * @remarks - * This works by doing reverse fan-out queries: - * 1. Load all entity configurations of entity types that reference this type of entity - * 2. For each entity configuration, find all fields that contain edges to this type of entity - * 3. For each edge field, load all entities with an edge from target entity to this entity via that field - * 4. Perform desired OnDeleteBehavior for entities - * - * @param entity - entity to find all references to + * Commit the batch of new entities after authorizing each against creation privacy rules. + * If any item fails authorization, the entire batch is aborted. + * @returns authorized, cached, newly-created entities result, where result error can be UnauthorizedError */ - private async processEntityDeletionForInboundEdgesAsync( - entity: TEntity, - queryContext: EntityTransactionalQueryContext, - processedEntityIdentifiers: Set, - ): Promise { - // prevent infinite reference cycles by keeping track of entities already processed - if (processedEntityIdentifiers.has(entity.getUniqueIdentifier())) { - return; + async createAsync(): Promise> { + if (this.fieldObjects.length === 0) { + return result([]); } - processedEntityIdentifiers.add(entity.getUniqueIdentifier()); - - const companionDefinition = this.companionProvider.getCompanionForEntity( - entity.constructor as IEntityClass< - TFields, - TIDField, - TViewerContext, - TEntity, - TPrivacyPolicy, - TSelectedFields - >, - ).entityCompanionDefinition; - const entityConfiguration = companionDefinition.entityConfiguration; - const inboundEdges = entityConfiguration.inboundEdges; + return await timeAndLogMutationEventAsync( + this.metricsAdapter, + EntityMetricsMutationType.CREATE, + this.entityClass.name, + this.queryContext, + )(this.createInTransactionAsync()); + } - const newCascadingDeleteCause = { - entity, - cascadingDeleteCause: this.cascadingDeleteCause, - }; + private async createInTransactionAsync(): Promise> { + return await this.queryContext.runInTransactionIfNotInTransactionAsync((innerQueryContext) => + this.createInternalAsync(innerQueryContext), + ); + } - await Promise.all( - inboundEdges.map(async (entityClass) => { - const loaderFactory = entity - .getViewerContext() - .getViewerScopedEntityCompanionForClass(entityClass) - .getLoaderFactory(); - const mutatorFactory = entity - .getViewerContext() - .getViewerScopedEntityCompanionForClass(entityClass) - .getMutatorFactory(); + private async createInternalAsync( + queryContext: EntityTransactionalQueryContext, + ): Promise> { + // 1. Validate fields for ALL items + for (const fieldObject of this.fieldObjects) { + this.validateFields(fieldObject); + } - return await mapMapAsync( - this.companionProvider.getCompanionForEntity(entityClass).entityCompanionDefinition - .entityConfiguration.schema, - async (fieldDefinition, fieldName) => { - const association = fieldDefinition.association; - if (!association) { - return; - } + const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext, { + previousValue: null, + cascadingDeleteCause: null, + }); - const associatedConfiguration = this.companionProvider.getCompanionForEntity( - association.associatedEntityClass, - ).entityCompanionDefinition.entityConfiguration; - if (associatedConfiguration !== entityConfiguration) { - return; - } + // 2. Construct temp entities and authorize create for each + const temporaryEntities: TEntity[] = []; + for (const fieldObject of this.fieldObjects) { + const temporaryEntity = entityLoader.constructionUtils.constructEntity({ + [this.entityConfiguration.idField]: '00000000-0000-0000-0000-000000000000', + ...fieldObject, + } as unknown as TFields); - const inboundReferenceEntities = await enforceResultsAsync( - loaderFactory - .forLoad(queryContext, { - previousValue: null, - cascadingDeleteCause: newCascadingDeleteCause, - }) - .loadManyByFieldEqualingAsync( - fieldName, - association.associatedEntityLookupByField - ? entity.getField(association.associatedEntityLookupByField as any) - : entity.getID(), - ), - ); + const authorizeCreateResult = await asyncResult( + this.privacyPolicy.authorizeCreateAsync( + this.viewerContext, + queryContext, + { previousValue: null, cascadingDeleteCause: null }, + temporaryEntity, + this.metricsAdapter, + ), + ); + if (!authorizeCreateResult.ok) { + return result(authorizeCreateResult.reason); + } - switch (association.edgeDeletionBehavior) { - case EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY: { - await Promise.all( - inboundReferenceEntities.map((inboundReferenceEntity) => - enforceAsyncResult( - mutatorFactory - .forDelete(inboundReferenceEntity, queryContext, newCascadingDeleteCause) - .deleteInTransactionAsync( - processedEntityIdentifiers, - /* skipDatabaseDeletion */ true, // deletion is handled by DB - ), - ), - ), - ); - break; - } - case EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY: { - await Promise.all( - inboundReferenceEntities.map((inboundReferenceEntity) => - enforceAsyncResult( - mutatorFactory - .forUpdate(inboundReferenceEntity, queryContext, newCascadingDeleteCause) - .setField(fieldName, null) - ['updateInTransactionAsync'](/* skipDatabaseUpdate */ true), - ), - ), - ); - break; - } - case EntityEdgeDeletionBehavior.SET_NULL: { - await Promise.all( - inboundReferenceEntities.map((inboundReferenceEntity) => - enforceAsyncResult( - mutatorFactory - .forUpdate(inboundReferenceEntity, queryContext, newCascadingDeleteCause) - .setField(fieldName, null) - ['updateInTransactionAsync'](/* skipDatabaseUpdate */ false), - ), - ), - ); - break; - } - case EntityEdgeDeletionBehavior.CASCADE_DELETE: { - await Promise.all( - inboundReferenceEntities.map((inboundReferenceEntity) => - enforceAsyncResult( - mutatorFactory - .forDelete(inboundReferenceEntity, queryContext, newCascadingDeleteCause) - .deleteInTransactionAsync( - processedEntityIdentifiers, - /* skipDatabaseDeletion */ false, - ), - ), - ), - ); - } - } + temporaryEntities.push(temporaryEntity); + } + + // 3. Run beforeCreateAndUpdate validators for each + for (const temporaryEntity of temporaryEntities) { + await this.executeMutationValidatorsAsync( + this.mutationValidators.beforeCreateAndUpdate, + queryContext, + temporaryEntity, + { type: EntityMutationType.CREATE }, + ); + } + + // 4. Run beforeAll triggers for each + for (const temporaryEntity of temporaryEntities) { + await this.executeMutationTriggersAsync( + this.mutationTriggers.beforeAll, + queryContext, + temporaryEntity, + { type: EntityMutationType.CREATE }, + ); + } + + // 5. Run beforeCreate triggers for each + for (const temporaryEntity of temporaryEntities) { + await this.executeMutationTriggersAsync( + this.mutationTriggers.beforeCreate, + queryContext, + temporaryEntity, + { type: EntityMutationType.CREATE }, + ); + } + + // 6. Batch insert — single multi-row INSERT + const insertResults = await this.databaseAdapter.batchInsertAsync( + queryContext, + this.fieldObjects, + ); + + // 7. Invalidate caches for each result + for (const insertResult of insertResults) { + queryContext.appendPostCommitInvalidationCallback(async () => { + entityLoader.invalidationUtils.invalidateFieldsForTransaction(queryContext, insertResult); + await entityLoader.invalidationUtils.invalidateFieldsAsync(insertResult); + }); + + entityLoader.invalidationUtils.invalidateFieldsForTransaction(queryContext, insertResult); + } + + // 8. Reload all entities via loadByIDAsync per entity + const newEntities: TEntity[] = []; + for (const insertResult of insertResults) { + const unauthorizedEntityAfterInsert = + entityLoader.constructionUtils.constructEntity(insertResult); + const newEntity = await enforceAsyncResult( + entityLoader.loadByIDAsync(unauthorizedEntityAfterInsert.getID()), + ); + newEntities.push(newEntity); + } + + // 9. Run afterCreate triggers for each + for (const newEntity of newEntities) { + await this.executeMutationTriggersAsync( + this.mutationTriggers.afterCreate, + queryContext, + newEntity, + { type: EntityMutationType.CREATE }, + ); + } + + // 10. Run afterAll triggers for each + for (const newEntity of newEntities) { + await this.executeMutationTriggersAsync( + this.mutationTriggers.afterAll, + queryContext, + newEntity, + { type: EntityMutationType.CREATE }, + ); + } + + // 11. Schedule afterCommit triggers for each + for (const newEntity of newEntities) { + queryContext.appendPostCommitCallback( + this.executeNonTransactionalMutationTriggersAsync.bind( + this, + this.mutationTriggers.afterCommit, + newEntity, + { type: EntityMutationType.CREATE }, + ), + ); + } + + return result(newEntities); + } +} + +/** + * Mutator for batch updating multiple existing entities in a single transaction with a single + * UPDATE ... WHERE IN statement. The same field changes are applied to all entities. + * Runs the full entity mutation pipeline (authorization, validation, triggers, cache invalidation) + * per-item. + */ +export class AuthorizationResultBasedBatchUpdateMutator< + TFields extends Record, + TIDField extends keyof NonNullable>, + TViewerContext extends ViewerContext, + TEntity extends Entity, + TPrivacyPolicy extends EntityPrivacyPolicy< + TFields, + TIDField, + TViewerContext, + TEntity, + TSelectedFields + >, + TSelectedFields extends keyof TFields, +> extends AuthorizationResultBasedBaseMutator< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields +> { + private readonly updatedFields: Partial = {}; + + constructor( + companionProvider: EntityCompanionProvider, + viewerContext: TViewerContext, + queryContext: EntityQueryContext, + entityConfiguration: EntityConfiguration, + entityClass: IEntityClass< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >, + privacyPolicy: TPrivacyPolicy, + mutationValidators: EntityMutationValidatorConfiguration< + TFields, + TIDField, + TViewerContext, + TEntity, + TSelectedFields + >, + mutationTriggers: EntityMutationTriggerConfiguration< + TFields, + TIDField, + TViewerContext, + TEntity, + TSelectedFields + >, + entityLoaderFactory: EntityLoaderFactory< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >, + databaseAdapter: EntityDatabaseAdapter, + metricsAdapter: IEntityMetricsAdapter, + private readonly originalEntities: readonly TEntity[], + ) { + super( + companionProvider, + viewerContext, + queryContext, + entityConfiguration, + entityClass, + privacyPolicy, + mutationValidators, + mutationTriggers, + entityLoaderFactory, + databaseAdapter, + metricsAdapter, + ); + } + + /** + * Set the value for entity field. Same value is applied to all entities in the batch. + * @param fieldName - entity field being updated + * @param value - value for entity field + */ + setField>(fieldName: K, value: TFields[K]): this { + this.updatedFields[fieldName] = value; + return this; + } + + /** + * Commit the changes to all entities after authorizing each against update privacy rules. + * If any item fails authorization, the entire batch is aborted. + * @returns authorized updated entities result, where result error can be UnauthorizedError + */ + async updateAsync(): Promise> { + if (this.originalEntities.length === 0) { + return result([]); + } + return await timeAndLogMutationEventAsync( + this.metricsAdapter, + EntityMetricsMutationType.UPDATE, + this.entityClass.name, + this.queryContext, + )(this.updateInTransactionAsync()); + } + + private async updateInTransactionAsync(): Promise> { + return await this.queryContext.runInTransactionIfNotInTransactionAsync((innerQueryContext) => + this.updateInternalAsync(innerQueryContext), + ); + } + + private async updateInternalAsync( + queryContext: EntityTransactionalQueryContext, + ): Promise> { + // 1. Validate updated fields once + ensure stable ID for each entity + this.validateFields(this.updatedFields); + for (const originalEntity of this.originalEntities) { + this.ensureStableIDField(originalEntity, this.updatedFields); + } + + // 2. Construct entity-about-to-be-updated and authorize update for each + const entityLoadersPerEntity: EntityLoaderForBatch< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >[] = []; + const entitiesAboutToBeUpdated: TEntity[] = []; + const fieldsForEntities: TFields[] = []; + + for (const originalEntity of this.originalEntities) { + const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext, { + previousValue: originalEntity, + cascadingDeleteCause: null, + }); + entityLoadersPerEntity.push(entityLoader); + + const fieldsForEntity = { + ...originalEntity.getAllDatabaseFields(), + ...this.updatedFields, + }; + fieldsForEntities.push(fieldsForEntity); + + const entityAboutToBeUpdated = + entityLoader.constructionUtils.constructEntity(fieldsForEntity); + entitiesAboutToBeUpdated.push(entityAboutToBeUpdated); + + const authorizeUpdateResult = await asyncResult( + this.privacyPolicy.authorizeUpdateAsync( + this.viewerContext, + queryContext, + { previousValue: originalEntity, cascadingDeleteCause: null }, + entityAboutToBeUpdated, + this.metricsAdapter, + ), + ); + if (!authorizeUpdateResult.ok) { + return result(authorizeUpdateResult.reason); + } + } + + // 3. Run beforeCreateAndUpdate validators for each + for (let i = 0; i < this.originalEntities.length; i++) { + await this.executeMutationValidatorsAsync( + this.mutationValidators.beforeCreateAndUpdate, + queryContext, + entitiesAboutToBeUpdated[i]!, + { + type: EntityMutationType.UPDATE, + previousValue: this.originalEntities[i]!, + cascadingDeleteCause: null, + }, + ); + } + + // 4. Run beforeAll triggers for each + for (let i = 0; i < this.originalEntities.length; i++) { + await this.executeMutationTriggersAsync( + this.mutationTriggers.beforeAll, + queryContext, + entitiesAboutToBeUpdated[i]!, + { + type: EntityMutationType.UPDATE, + previousValue: this.originalEntities[i]!, + cascadingDeleteCause: null, + }, + ); + } + + // 5. Run beforeUpdate triggers for each + for (let i = 0; i < this.originalEntities.length; i++) { + await this.executeMutationTriggersAsync( + this.mutationTriggers.beforeUpdate, + queryContext, + entitiesAboutToBeUpdated[i]!, + { + type: EntityMutationType.UPDATE, + previousValue: this.originalEntities[i]!, + cascadingDeleteCause: null, + }, + ); + } + + // 6. Batch update — single UPDATE ... WHERE IN + const ids = this.originalEntities.map((entity) => entity.getID()); + await this.databaseAdapter.batchUpdateAsync( + queryContext, + this.entityConfiguration.idField, + ids, + this.updatedFields, + ); + + // 7. Invalidate caches for both original and new field values per entity + for (let i = 0; i < this.originalEntities.length; i++) { + const entityLoader = entityLoadersPerEntity[i]!; + const originalFields = this.originalEntities[i]!.getAllDatabaseFields(); + const newFields = fieldsForEntities[i]!; + + queryContext.appendPostCommitInvalidationCallback(async () => { + entityLoader.invalidationUtils.invalidateFieldsForTransaction(queryContext, originalFields); + entityLoader.invalidationUtils.invalidateFieldsForTransaction(queryContext, newFields); + await Promise.all([ + entityLoader.invalidationUtils.invalidateFieldsAsync(originalFields), + entityLoader.invalidationUtils.invalidateFieldsAsync(newFields), + ]); + }); + + entityLoader.invalidationUtils.invalidateFieldsForTransaction(queryContext, originalFields); + entityLoader.invalidationUtils.invalidateFieldsForTransaction(queryContext, newFields); + } + + // 8. Reload all entities via per-entity loadByIDAsync + const updatedEntities: TEntity[] = []; + for (let i = 0; i < this.originalEntities.length; i++) { + const entityLoader = entityLoadersPerEntity[i]!; + const updatedEntity = await enforceAsyncResult( + entityLoader.loadByIDAsync(entitiesAboutToBeUpdated[i]!.getID()), + ); + updatedEntities.push(updatedEntity); + } + + // 9. Run afterUpdate triggers for each + for (let i = 0; i < this.originalEntities.length; i++) { + await this.executeMutationTriggersAsync( + this.mutationTriggers.afterUpdate, + queryContext, + updatedEntities[i]!, + { + type: EntityMutationType.UPDATE, + previousValue: this.originalEntities[i]!, + cascadingDeleteCause: null, + }, + ); + } + + // 10. Run afterAll triggers for each + for (let i = 0; i < this.originalEntities.length; i++) { + await this.executeMutationTriggersAsync( + this.mutationTriggers.afterAll, + queryContext, + updatedEntities[i]!, + { + type: EntityMutationType.UPDATE, + previousValue: this.originalEntities[i]!, + cascadingDeleteCause: null, + }, + ); + } + + // 11. Schedule afterCommit triggers for each + for (let i = 0; i < this.originalEntities.length; i++) { + queryContext.appendPostCommitCallback( + this.executeNonTransactionalMutationTriggersAsync.bind( + this, + this.mutationTriggers.afterCommit, + updatedEntities[i]!, + { + type: EntityMutationType.UPDATE, + previousValue: this.originalEntities[i]!, + cascadingDeleteCause: null, }, - ); - }), + ), + ); + } + + return result(updatedEntities); + } + + private ensureStableIDField(entity: TEntity, updatedFields: Partial): void { + const originalId = entity.getID(); + const idField = this.entityConfiguration.idField; + if (updatedFields.hasOwnProperty(idField) && originalId !== updatedFields[idField]) { + throw new Error(`id field updates not supported: (entityClass = ${this.entityClass.name})`); + } + } +} + +/** + * Mutator for batch deleting multiple existing entities in a single transaction with a single + * DELETE ... WHERE IN statement. Runs the full entity mutation pipeline (authorization, + * inbound edge processing, validation, triggers, cache invalidation) per-item. + */ +export class AuthorizationResultBasedBatchDeleteMutator< + TFields extends Record, + TIDField extends keyof NonNullable>, + TViewerContext extends ViewerContext, + TEntity extends Entity, + TPrivacyPolicy extends EntityPrivacyPolicy< + TFields, + TIDField, + TViewerContext, + TEntity, + TSelectedFields + >, + TSelectedFields extends keyof TFields, +> extends AuthorizationResultBasedBaseMutator< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields +> { + constructor( + companionProvider: EntityCompanionProvider, + viewerContext: TViewerContext, + queryContext: EntityQueryContext, + entityConfiguration: EntityConfiguration, + entityClass: IEntityClass< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >, + privacyPolicy: TPrivacyPolicy, + mutationValidators: EntityMutationValidatorConfiguration< + TFields, + TIDField, + TViewerContext, + TEntity, + TSelectedFields + >, + mutationTriggers: EntityMutationTriggerConfiguration< + TFields, + TIDField, + TViewerContext, + TEntity, + TSelectedFields + >, + entityLoaderFactory: EntityLoaderFactory< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >, + databaseAdapter: EntityDatabaseAdapter, + metricsAdapter: IEntityMetricsAdapter, + private readonly entities: readonly TEntity[], + ) { + super( + companionProvider, + viewerContext, + queryContext, + entityConfiguration, + entityClass, + privacyPolicy, + mutationValidators, + mutationTriggers, + entityLoaderFactory, + databaseAdapter, + metricsAdapter, ); } + + /** + * Delete all entities after authorizing each against delete privacy rules. + * If any item fails authorization, the entire batch is aborted. + * @returns void result, where result error can be UnauthorizedError + */ + async deleteAsync(): Promise> { + if (this.entities.length === 0) { + return result(); + } + return await timeAndLogMutationEventAsync( + this.metricsAdapter, + EntityMetricsMutationType.DELETE, + this.entityClass.name, + this.queryContext, + )(this.deleteInTransactionAsync()); + } + + private async deleteInTransactionAsync(): Promise> { + return await this.queryContext.runInTransactionIfNotInTransactionAsync((innerQueryContext) => + this.deleteInternalAsync(innerQueryContext), + ); + } + + private async deleteInternalAsync( + queryContext: EntityTransactionalQueryContext, + ): Promise> { + const processedEntityIdentifiers = new Set(); + + // 1. Authorize delete for each entity + for (const entity of this.entities) { + const authorizeDeleteResult = await asyncResult( + this.privacyPolicy.authorizeDeleteAsync( + this.viewerContext, + queryContext, + { previousValue: null, cascadingDeleteCause: null }, + entity, + this.metricsAdapter, + ), + ); + if (!authorizeDeleteResult.ok) { + return authorizeDeleteResult; + } + } + + // 2. Process inbound edge deletions for each entity + for (const entity of this.entities) { + await this.processEntityDeletionForInboundEdgesAsync( + entity, + null, + queryContext, + processedEntityIdentifiers, + ); + } + + // 3. Run beforeDelete validators for each + for (const entity of this.entities) { + await this.executeMutationValidatorsAsync( + this.mutationValidators.beforeDelete, + queryContext, + entity, + { + type: EntityMutationType.DELETE, + cascadingDeleteCause: null, + }, + ); + } + + // 4. Run beforeAll triggers for each + for (const entity of this.entities) { + await this.executeMutationTriggersAsync( + this.mutationTriggers.beforeAll, + queryContext, + entity, + { + type: EntityMutationType.DELETE, + cascadingDeleteCause: null, + }, + ); + } + + // 5. Run beforeDelete triggers for each + for (const entity of this.entities) { + await this.executeMutationTriggersAsync( + this.mutationTriggers.beforeDelete, + queryContext, + entity, + { + type: EntityMutationType.DELETE, + cascadingDeleteCause: null, + }, + ); + } + + // 6. Batch delete — single DELETE ... WHERE IN + const ids = this.entities.map((entity) => entity.getID()); + await this.databaseAdapter.batchDeleteAsync( + queryContext, + this.entityConfiguration.idField, + ids, + ); + + // 7. Invalidate caches for each entity + for (const entity of this.entities) { + const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext, { + previousValue: null, + cascadingDeleteCause: null, + }); + + queryContext.appendPostCommitInvalidationCallback(async () => { + entityLoader.invalidationUtils.invalidateFieldsForTransaction( + queryContext, + entity.getAllDatabaseFields(), + ); + await entityLoader.invalidationUtils.invalidateFieldsAsync(entity.getAllDatabaseFields()); + }); + entityLoader.invalidationUtils.invalidateFieldsForTransaction( + queryContext, + entity.getAllDatabaseFields(), + ); + } + + // 8. Run afterDelete triggers for each + for (const entity of this.entities) { + await this.executeMutationTriggersAsync( + this.mutationTriggers.afterDelete, + queryContext, + entity, + { + type: EntityMutationType.DELETE, + cascadingDeleteCause: null, + }, + ); + } + + // 9. Run afterAll triggers for each + for (const entity of this.entities) { + await this.executeMutationTriggersAsync( + this.mutationTriggers.afterAll, + queryContext, + entity, + { + type: EntityMutationType.DELETE, + cascadingDeleteCause: null, + }, + ); + } + + // 10. Schedule afterCommit triggers for each + for (const entity of this.entities) { + queryContext.appendPostCommitCallback( + this.executeNonTransactionalMutationTriggersAsync.bind( + this, + this.mutationTriggers.afterCommit, + entity, + { + type: EntityMutationType.DELETE, + cascadingDeleteCause: null, + }, + ), + ); + } + + return result(); + } } diff --git a/packages/entity/src/EnforcingEntityBatchCreator.ts b/packages/entity/src/EnforcingEntityBatchCreator.ts new file mode 100644 index 000000000..9835630c2 --- /dev/null +++ b/packages/entity/src/EnforcingEntityBatchCreator.ts @@ -0,0 +1,44 @@ +import { enforceAsyncResult } from '@expo/results'; + +import { AuthorizationResultBasedBatchCreateMutator } from './AuthorizationResultBasedEntityMutator'; +import { EntityPrivacyPolicy } from './EntityPrivacyPolicy'; +import { ReadonlyEntity } from './ReadonlyEntity'; +import { ViewerContext } from './ViewerContext'; + +/** + * Enforcing entity batch creator. All creates + * through this creator will throw if authorization is not successful. + */ +export class EnforcingEntityBatchCreator< + 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 batchCreator: AuthorizationResultBasedBatchCreateMutator< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >, + ) {} + + /** + * Commit the batch of new entities after authorizing each against creation privacy rules. + * @returns authorized, cached, newly-created entities, throwing if unsuccessful + */ + async createAsync(): Promise { + return await enforceAsyncResult(this.batchCreator.createAsync()); + } +} diff --git a/packages/entity/src/EnforcingEntityBatchDeleter.ts b/packages/entity/src/EnforcingEntityBatchDeleter.ts new file mode 100644 index 000000000..f8672f1ce --- /dev/null +++ b/packages/entity/src/EnforcingEntityBatchDeleter.ts @@ -0,0 +1,44 @@ +import { enforceAsyncResult } from '@expo/results'; + +import { AuthorizationResultBasedBatchDeleteMutator } from './AuthorizationResultBasedEntityMutator'; +import { EntityPrivacyPolicy } from './EntityPrivacyPolicy'; +import { ReadonlyEntity } from './ReadonlyEntity'; +import { ViewerContext } from './ViewerContext'; + +/** + * Enforcing entity batch deleter. All deletes + * through this deleter will throw if authorization is not successful. + */ +export class EnforcingEntityBatchDeleter< + 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 batchDeleter: AuthorizationResultBasedBatchDeleteMutator< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >, + ) {} + + /** + * Delete all entities after authorizing each against delete privacy rules. + * @returns void, throws upon delete failure + */ + async deleteAsync(): Promise { + await enforceAsyncResult(this.batchDeleter.deleteAsync()); + } +} diff --git a/packages/entity/src/EnforcingEntityBatchUpdater.ts b/packages/entity/src/EnforcingEntityBatchUpdater.ts new file mode 100644 index 000000000..4cb80c891 --- /dev/null +++ b/packages/entity/src/EnforcingEntityBatchUpdater.ts @@ -0,0 +1,54 @@ +import { enforceAsyncResult } from '@expo/results'; + +import { AuthorizationResultBasedBatchUpdateMutator } from './AuthorizationResultBasedEntityMutator'; +import { EntityPrivacyPolicy } from './EntityPrivacyPolicy'; +import { ReadonlyEntity } from './ReadonlyEntity'; +import { ViewerContext } from './ViewerContext'; + +/** + * Enforcing entity batch updater. All updates + * through this updater will throw if authorization is not successful. + */ +export class EnforcingEntityBatchUpdater< + 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 batchUpdater: AuthorizationResultBasedBatchUpdateMutator< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >, + ) {} + + /** + * Set the value for entity field. Same value is applied to all entities in the batch. + * @param fieldName - entity field being updated + * @param value - value for entity field + */ + setField>(fieldName: K, value: TFields[K]): this { + this.batchUpdater.setField(fieldName, value); + return this; + } + + /** + * Commit the changes to all entities after authorizing each against update privacy rules. + * @returns authorized updated entities, throws upon update failure + */ + async updateAsync(): Promise { + return await enforceAsyncResult(this.batchUpdater.updateAsync()); + } +} diff --git a/packages/entity/src/Entity.ts b/packages/entity/src/Entity.ts index ed47100d9..2852554b5 100644 --- a/packages/entity/src/Entity.ts +++ b/packages/entity/src/Entity.ts @@ -1,11 +1,20 @@ import { + AuthorizationResultBasedBatchCreateMutator, + AuthorizationResultBasedBatchDeleteMutator, + AuthorizationResultBasedBatchUpdateMutator, AuthorizationResultBasedCreateMutator, AuthorizationResultBasedDeleteMutator, AuthorizationResultBasedUpdateMutator, } from './AuthorizationResultBasedEntityMutator'; +import { EnforcingEntityBatchCreator } from './EnforcingEntityBatchCreator'; +import { EnforcingEntityBatchDeleter } from './EnforcingEntityBatchDeleter'; +import { EnforcingEntityBatchUpdater } from './EnforcingEntityBatchUpdater'; import { EnforcingEntityCreator } from './EnforcingEntityCreator'; import { EnforcingEntityDeleter } from './EnforcingEntityDeleter'; import { EnforcingEntityUpdater } from './EnforcingEntityUpdater'; +import { EntityBatchCreator } from './EntityBatchCreator'; +import { EntityBatchDeleter } from './EntityBatchDeleter'; +import { EntityBatchUpdater } from './EntityBatchUpdater'; import { EntityCompanionDefinition } from './EntityCompanionProvider'; import { EntityCreator } from './EntityCreator'; import { EntityDeleter } from './EntityDeleter'; @@ -308,6 +317,285 @@ export abstract class Entity< > { return new EntityDeleter(existingEntity, queryContext, this).withAuthorizationResults(); } + + /** + * Vend mutator for batch creating multiple new entities in given query context. + * @param viewerContext - viewer context of creating user + * @param fieldObjects - field values for each entity to create + * @param queryContext - query context in which to perform the batch create + * @returns enforcing mutator for batch creating entities + */ + static batchCreator< + TMFields extends object, + TMIDField extends keyof NonNullable>, + TMViewerContext extends ViewerContext, + TMViewerContext2 extends TMViewerContext, + TMEntity extends Entity, + TMPrivacyPolicy extends EntityPrivacyPolicy< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMSelectedFields + >, + TMSelectedFields extends keyof TMFields = keyof TMFields, + >( + this: IEntityClass< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + >, + viewerContext: TMViewerContext2, + fieldObjects: readonly Readonly>[], + queryContext: EntityQueryContext = viewerContext + .getViewerScopedEntityCompanionForClass(this) + .getQueryContextProvider() + .getQueryContext(), + ): EnforcingEntityBatchCreator< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + > { + return new EntityBatchCreator(viewerContext, queryContext, this, fieldObjects).enforcing(); + } + + /** + * Vend mutator for batch creating multiple new entities in given query context. + * @param viewerContext - viewer context of creating user + * @param fieldObjects - field values for each entity to create + * @param queryContext - query context in which to perform the batch create + * @returns authorization-result-based mutator for batch creating entities + */ + static batchCreatorWithAuthorizationResults< + TMFields extends object, + TMIDField extends keyof NonNullable>, + TMViewerContext extends ViewerContext, + TMViewerContext2 extends TMViewerContext, + TMEntity extends Entity, + TMPrivacyPolicy extends EntityPrivacyPolicy< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMSelectedFields + >, + TMSelectedFields extends keyof TMFields = keyof TMFields, + >( + this: IEntityClass< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + >, + viewerContext: TMViewerContext2, + fieldObjects: readonly Readonly>[], + queryContext: EntityQueryContext = viewerContext + .getViewerScopedEntityCompanionForClass(this) + .getQueryContextProvider() + .getQueryContext(), + ): AuthorizationResultBasedBatchCreateMutator< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + > { + return new EntityBatchCreator( + viewerContext, + queryContext, + this, + fieldObjects, + ).withAuthorizationResults(); + } + + /** + * Vend mutator for batch updating multiple existing entities in given query context. + * @param existingEntities - entities to update (must have at least one) + * @param queryContext - query context in which to perform the batch update + * @returns enforcing mutator for batch updating entities + */ + static batchUpdater< + TMFields extends object, + TMIDField extends keyof NonNullable>, + TMViewerContext extends ViewerContext, + TMEntity extends Entity, + TMPrivacyPolicy extends EntityPrivacyPolicy< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMSelectedFields + >, + TMSelectedFields extends keyof TMFields = keyof TMFields, + >( + this: IEntityClass< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + >, + existingEntities: readonly TMEntity[], + queryContext: EntityQueryContext = existingEntities[0]! + .getViewerContext() + .getViewerScopedEntityCompanionForClass(this) + .getQueryContextProvider() + .getQueryContext(), + ): EnforcingEntityBatchUpdater< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + > { + return new EntityBatchUpdater(existingEntities, queryContext, this).enforcing(); + } + + /** + * Vend mutator for batch updating multiple existing entities in given query context. + * @param existingEntities - entities to update (must have at least one) + * @param queryContext - query context in which to perform the batch update + * @returns authorization-result-based mutator for batch updating entities + */ + static batchUpdaterWithAuthorizationResults< + TMFields extends object, + TMIDField extends keyof NonNullable>, + TMViewerContext extends ViewerContext, + TMEntity extends Entity, + TMPrivacyPolicy extends EntityPrivacyPolicy< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMSelectedFields + >, + TMSelectedFields extends keyof TMFields = keyof TMFields, + >( + this: IEntityClass< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + >, + existingEntities: readonly TMEntity[], + queryContext: EntityQueryContext = existingEntities[0]! + .getViewerContext() + .getViewerScopedEntityCompanionForClass(this) + .getQueryContextProvider() + .getQueryContext(), + ): AuthorizationResultBasedBatchUpdateMutator< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + > { + return new EntityBatchUpdater(existingEntities, queryContext, this).withAuthorizationResults(); + } + + /** + * Vend mutator for batch deleting multiple existing entities in given query context. + * @param existingEntities - entities to delete (must have at least one) + * @param queryContext - query context in which to perform the batch delete + * @returns enforcing mutator for batch deleting entities + */ + static batchDeleter< + TMFields extends object, + TMIDField extends keyof NonNullable>, + TMViewerContext extends ViewerContext, + TMEntity extends Entity, + TMPrivacyPolicy extends EntityPrivacyPolicy< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMSelectedFields + >, + TMSelectedFields extends keyof TMFields = keyof TMFields, + >( + this: IEntityClass< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + >, + existingEntities: readonly TMEntity[], + queryContext: EntityQueryContext = existingEntities[0]! + .getViewerContext() + .getViewerScopedEntityCompanionForClass(this) + .getQueryContextProvider() + .getQueryContext(), + ): EnforcingEntityBatchDeleter< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + > { + return new EntityBatchDeleter(existingEntities, queryContext, this).enforcing(); + } + + /** + * Vend mutator for batch deleting multiple existing entities in given query context. + * @param existingEntities - entities to delete (must have at least one) + * @param queryContext - query context in which to perform the batch delete + * @returns authorization-result-based mutator for batch deleting entities + */ + static batchDeleterWithAuthorizationResults< + TMFields extends object, + TMIDField extends keyof NonNullable>, + TMViewerContext extends ViewerContext, + TMEntity extends Entity, + TMPrivacyPolicy extends EntityPrivacyPolicy< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMSelectedFields + >, + TMSelectedFields extends keyof TMFields = keyof TMFields, + >( + this: IEntityClass< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + >, + existingEntities: readonly TMEntity[], + queryContext: EntityQueryContext = existingEntities[0]! + .getViewerContext() + .getViewerScopedEntityCompanionForClass(this) + .getQueryContextProvider() + .getQueryContext(), + ): AuthorizationResultBasedBatchDeleteMutator< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + > { + return new EntityBatchDeleter(existingEntities, queryContext, this).withAuthorizationResults(); + } } /** diff --git a/packages/entity/src/EntityBatchCreator.ts b/packages/entity/src/EntityBatchCreator.ts new file mode 100644 index 000000000..e3987471f --- /dev/null +++ b/packages/entity/src/EntityBatchCreator.ts @@ -0,0 +1,74 @@ +import { AuthorizationResultBasedBatchCreateMutator } from './AuthorizationResultBasedEntityMutator'; +import { EnforcingEntityBatchCreator } from './EnforcingEntityBatchCreator'; +import { IEntityClass } from './Entity'; +import { EntityPrivacyPolicy } from './EntityPrivacyPolicy'; +import { EntityQueryContext } from './EntityQueryContext'; +import { ReadonlyEntity } from './ReadonlyEntity'; +import { ViewerContext } from './ViewerContext'; + +/** + * The primary interface for batch creating entities. + */ +export class EntityBatchCreator< + TFields extends Record, + TIDField extends keyof NonNullable>, + TViewerContext extends ViewerContext, + TViewerContext2 extends TViewerContext, + TEntity extends ReadonlyEntity, + TPrivacyPolicy extends EntityPrivacyPolicy< + TFields, + TIDField, + TViewerContext, + TEntity, + TSelectedFields + >, + TSelectedFields extends keyof TFields, +> { + constructor( + private readonly viewerContext: TViewerContext2, + private readonly queryContext: EntityQueryContext, + private readonly entityClass: IEntityClass< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >, + private readonly fieldObjects: readonly Readonly>[], + ) {} + + /** + * Enforcing entity batch creator. All creates through this creator are + * guaranteed to be successful and will throw otherwise. + */ + enforcing(): EnforcingEntityBatchCreator< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + > { + return new EnforcingEntityBatchCreator(this.withAuthorizationResults()); + } + + /** + * Authorization-result-based entity batch creator. All creates through this + * creator are results, where an unsuccessful result means an authorization + * error or entity construction error occurred. Other errors are thrown. + */ + withAuthorizationResults(): AuthorizationResultBasedBatchCreateMutator< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + > { + return this.viewerContext + .getViewerScopedEntityCompanionForClass(this.entityClass) + .getMutatorFactory() + .forBatchCreate(this.queryContext, this.fieldObjects); + } +} diff --git a/packages/entity/src/EntityBatchDeleter.ts b/packages/entity/src/EntityBatchDeleter.ts new file mode 100644 index 000000000..68459b343 --- /dev/null +++ b/packages/entity/src/EntityBatchDeleter.ts @@ -0,0 +1,75 @@ +import invariant from 'invariant'; + +import { AuthorizationResultBasedBatchDeleteMutator } from './AuthorizationResultBasedEntityMutator'; +import { EnforcingEntityBatchDeleter } from './EnforcingEntityBatchDeleter'; +import { IEntityClass } from './Entity'; +import { EntityPrivacyPolicy } from './EntityPrivacyPolicy'; +import { EntityQueryContext } from './EntityQueryContext'; +import { ReadonlyEntity } from './ReadonlyEntity'; +import { ViewerContext } from './ViewerContext'; + +/** + * The primary interface for batch deleting entities. + */ +export class EntityBatchDeleter< + 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 existingEntities: readonly TEntity[], + private readonly queryContext: EntityQueryContext, + private readonly entityClass: IEntityClass< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >, + ) {} + + /** + * Enforcing entity batch deleter. All deletes through this deleter are + * guaranteed to be successful and will throw otherwise. + */ + enforcing(): EnforcingEntityBatchDeleter< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + > { + return new EnforcingEntityBatchDeleter(this.withAuthorizationResults()); + } + + /** + * Authorization-result-based entity batch deleter. All deletes through this + * deleter are results, where an unsuccessful result means an authorization + * error or entity construction error occurred. Other errors are thrown. + */ + withAuthorizationResults(): AuthorizationResultBasedBatchDeleteMutator< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + > { + invariant(this.existingEntities.length > 0, 'EntityBatchDeleter requires at least one entity'); + return this.existingEntities[0]!.getViewerContext() + .getViewerScopedEntityCompanionForClass(this.entityClass) + .getMutatorFactory() + .forBatchDelete(this.existingEntities, this.queryContext); + } +} diff --git a/packages/entity/src/EntityBatchUpdater.ts b/packages/entity/src/EntityBatchUpdater.ts new file mode 100644 index 000000000..4d74bf46e --- /dev/null +++ b/packages/entity/src/EntityBatchUpdater.ts @@ -0,0 +1,75 @@ +import invariant from 'invariant'; + +import { AuthorizationResultBasedBatchUpdateMutator } from './AuthorizationResultBasedEntityMutator'; +import { EnforcingEntityBatchUpdater } from './EnforcingEntityBatchUpdater'; +import { IEntityClass } from './Entity'; +import { EntityPrivacyPolicy } from './EntityPrivacyPolicy'; +import { EntityQueryContext } from './EntityQueryContext'; +import { ReadonlyEntity } from './ReadonlyEntity'; +import { ViewerContext } from './ViewerContext'; + +/** + * The primary interface for batch updating entities. + */ +export class EntityBatchUpdater< + 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 existingEntities: readonly TEntity[], + private readonly queryContext: EntityQueryContext, + private readonly entityClass: IEntityClass< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >, + ) {} + + /** + * Enforcing entity batch updater. All updates through this updater are + * guaranteed to be successful and will throw otherwise. + */ + enforcing(): EnforcingEntityBatchUpdater< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + > { + return new EnforcingEntityBatchUpdater(this.withAuthorizationResults()); + } + + /** + * Authorization-result-based entity batch updater. All updates through this + * updater are results, where an unsuccessful result means an authorization + * error or entity construction error occurred. Other errors are thrown. + */ + withAuthorizationResults(): AuthorizationResultBasedBatchUpdateMutator< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + > { + invariant(this.existingEntities.length > 0, 'EntityBatchUpdater requires at least one entity'); + return this.existingEntities[0]!.getViewerContext() + .getViewerScopedEntityCompanionForClass(this.entityClass) + .getMutatorFactory() + .forBatchUpdate(this.existingEntities, this.queryContext); + } +} diff --git a/packages/entity/src/EntityDatabaseAdapter.ts b/packages/entity/src/EntityDatabaseAdapter.ts index a1a3142e9..e69a03e4b 100644 --- a/packages/entity/src/EntityDatabaseAdapter.ts +++ b/packages/entity/src/EntityDatabaseAdapter.ts @@ -3,6 +3,9 @@ import invariant from 'invariant'; import { EntityConfiguration } from './EntityConfiguration'; import { EntityQueryContext } from './EntityQueryContext'; import { + EntityDatabaseAdapterBatchDeleteExcessiveResultError, + EntityDatabaseAdapterBatchInsertMismatchResultError, + EntityDatabaseAdapterBatchUpdateMismatchResultError, EntityDatabaseAdapterEmptyInsertResultError, EntityDatabaseAdapterEmptyUpdateResultError, EntityDatabaseAdapterExcessiveDeleteResultError, @@ -252,6 +255,149 @@ export abstract class EntityDatabaseAdapter< object: object, ): Promise; + /** + * Batch insert multiple objects in a single SQL statement. + * + * @param queryContext - query context with which to perform the insert + * @param objects - the objects to insert + * @returns the inserted objects + */ + async batchInsertAsync( + queryContext: EntityQueryContext, + objects: readonly Readonly>[], + ): Promise[]> { + if (objects.length === 0) { + return []; + } + + const dbObjects = objects.map((object) => + transformFieldsToDatabaseObject(this.entityConfiguration, this.fieldTransformerMap, object), + ); + const results = await this.batchInsertInternalAsync( + queryContext.getQueryInterface(), + this.entityConfiguration.tableName, + dbObjects, + ); + + if (results.length !== objects.length) { + throw new EntityDatabaseAdapterBatchInsertMismatchResultError( + `Batch insert result count (${results.length}) does not match input count (${objects.length}): ${this.entityConfiguration.tableName}`, + ); + } + + return results.map((result) => + transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result), + ); + } + + protected abstract batchInsertInternalAsync( + queryInterface: any, + tableName: string, + objects: readonly object[], + ): Promise; + + /** + * Batch update multiple objects by ID in a single SQL statement. + * The same field values are applied to all rows matching the given IDs. + * + * @param queryContext - query context with which to perform the update + * @param idField - the field in the object that is the ID + * @param ids - the values of the ID field to update + * @param object - the field values to apply to all matching rows + * @returns the updated objects, in the same order as the input IDs + */ + async batchUpdateAsync( + queryContext: EntityQueryContext, + idField: K, + ids: readonly TFields[K][], + object: Readonly>, + ): Promise[]> { + if (ids.length === 0) { + return []; + } + + const idColumn = getDatabaseFieldForEntityField(this.entityConfiguration, idField); + const dbObject = transformFieldsToDatabaseObject( + this.entityConfiguration, + this.fieldTransformerMap, + object, + ); + const results = await this.batchUpdateInternalAsync( + queryContext.getQueryInterface(), + this.entityConfiguration.tableName, + idColumn, + ids, + dbObject, + ); + + if (results.length !== ids.length) { + throw new EntityDatabaseAdapterBatchUpdateMismatchResultError( + `Batch update result count (${results.length}) does not match input ID count (${ids.length}): ${this.entityConfiguration.tableName}`, + ); + } + + const transformedResults = results.map((result) => + transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result), + ); + + // Re-order results to match input ID order (Postgres WHERE IN doesn't guarantee order) + const resultMap = new Map>(); + for (const transformedResult of transformedResults) { + resultMap.set(transformedResult[idField], transformedResult); + } + return ids.map((id) => { + const result = resultMap.get(id); + invariant(result, `Missing result for ID ${String(id)} after batch update`); + return result; + }); + } + + protected abstract batchUpdateInternalAsync( + queryInterface: any, + tableName: string, + tableIdField: string, + ids: readonly any[], + object: object, + ): Promise; + + /** + * Batch delete multiple objects by ID in a single SQL statement. + * + * @param queryContext - query context with which to perform the deletion + * @param idField - the field in the object that is the ID + * @param ids - the values of the ID field to delete + */ + async batchDeleteAsync( + queryContext: EntityQueryContext, + idField: K, + ids: readonly TFields[K][], + ): Promise { + if (ids.length === 0) { + return; + } + + const idColumn = getDatabaseFieldForEntityField(this.entityConfiguration, idField); + const numDeleted = await this.batchDeleteInternalAsync( + queryContext.getQueryInterface(), + this.entityConfiguration.tableName, + idColumn, + ids, + ); + + if (numDeleted > ids.length) { + throw new EntityDatabaseAdapterBatchDeleteExcessiveResultError( + `Excessive deletions from database adapter batch delete: ${this.entityConfiguration.tableName} (expected <= ${ids.length}, got ${numDeleted})`, + ); + } + } + + protected abstract batchDeleteInternalAsync( + queryInterface: any, + tableName: string, + tableIdField: string, + ids: readonly any[], + ): Promise; + /** * Delete an object by ID. * diff --git a/packages/entity/src/EntityMutatorFactory.ts b/packages/entity/src/EntityMutatorFactory.ts index fc8efbfe5..fbcce80be 100644 --- a/packages/entity/src/EntityMutatorFactory.ts +++ b/packages/entity/src/EntityMutatorFactory.ts @@ -1,4 +1,7 @@ import { + AuthorizationResultBasedBatchCreateMutator, + AuthorizationResultBasedBatchDeleteMutator, + AuthorizationResultBasedBatchUpdateMutator, AuthorizationResultBasedCreateMutator, AuthorizationResultBasedDeleteMutator, AuthorizationResultBasedUpdateMutator, @@ -171,4 +174,113 @@ export class EntityMutatorFactory< cascadingDeleteCause, ); } + + /** + * Vend mutator for batch creating multiple new entities in given query context. + * @param viewerContext - viewer context of creating user + * @param queryContext - query context in which to perform the batch create + * @param fieldObjects - field values for each entity to create + * @returns mutator for batch creating entities + */ + forBatchCreate( + viewerContext: TViewerContext, + queryContext: EntityQueryContext, + fieldObjects: readonly Readonly>[], + ): AuthorizationResultBasedBatchCreateMutator< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + > { + return new AuthorizationResultBasedBatchCreateMutator( + this.entityCompanionProvider, + viewerContext, + queryContext, + this.entityConfiguration, + this.entityClass, + this.privacyPolicy, + this.mutationValidators, + this.mutationTriggers, + this.entityLoaderFactory, + this.databaseAdapter, + this.metricsAdapter, + fieldObjects, + ); + } + + /** + * Vend mutator for batch deleting multiple existing entities in given query context. + * @param existingEntities - entities to delete + * @param queryContext - query context in which to perform the batch delete + * @returns mutator for batch deleting entities + */ + forBatchDelete( + existingEntities: readonly TEntity[], + queryContext: EntityQueryContext, + ): AuthorizationResultBasedBatchDeleteMutator< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + > { + const viewerContext = + existingEntities.length > 0 + ? existingEntities[0]!.getViewerContext() + : (undefined as unknown as TViewerContext); + return new AuthorizationResultBasedBatchDeleteMutator( + this.entityCompanionProvider, + viewerContext, + queryContext, + this.entityConfiguration, + this.entityClass, + this.privacyPolicy, + this.mutationValidators, + this.mutationTriggers, + this.entityLoaderFactory, + this.databaseAdapter, + this.metricsAdapter, + existingEntities, + ); + } + + /** + * Vend mutator for batch updating multiple existing entities in given query context. + * @param existingEntities - entities to update + * @param queryContext - query context in which to perform the batch update + * @returns mutator for batch updating entities + */ + forBatchUpdate( + existingEntities: readonly TEntity[], + queryContext: EntityQueryContext, + ): AuthorizationResultBasedBatchUpdateMutator< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + > { + const viewerContext = + existingEntities.length > 0 + ? existingEntities[0]!.getViewerContext() + : (undefined as unknown as TViewerContext); + return new AuthorizationResultBasedBatchUpdateMutator( + this.entityCompanionProvider, + viewerContext, + queryContext, + this.entityConfiguration, + this.entityClass, + this.privacyPolicy, + this.mutationValidators, + this.mutationTriggers, + this.entityLoaderFactory, + this.databaseAdapter, + this.metricsAdapter, + existingEntities, + ); + } } diff --git a/packages/entity/src/ViewerScopedEntityMutatorFactory.ts b/packages/entity/src/ViewerScopedEntityMutatorFactory.ts index ef6eb7de2..771ba4ea1 100644 --- a/packages/entity/src/ViewerScopedEntityMutatorFactory.ts +++ b/packages/entity/src/ViewerScopedEntityMutatorFactory.ts @@ -1,4 +1,7 @@ import { + AuthorizationResultBasedBatchCreateMutator, + AuthorizationResultBasedBatchDeleteMutator, + AuthorizationResultBasedBatchUpdateMutator, AuthorizationResultBasedCreateMutator, AuthorizationResultBasedDeleteMutator, AuthorizationResultBasedUpdateMutator, @@ -81,4 +84,46 @@ export class ViewerScopedEntityMutatorFactory< > { return this.entityMutatorFactory.forDelete(existingEntity, queryContext, cascadingDeleteCause); } + + forBatchCreate( + queryContext: EntityQueryContext, + fieldObjects: readonly Readonly>[], + ): AuthorizationResultBasedBatchCreateMutator< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + > { + return this.entityMutatorFactory.forBatchCreate(this.viewerContext, queryContext, fieldObjects); + } + + forBatchDelete( + existingEntities: readonly TEntity[], + queryContext: EntityQueryContext, + ): AuthorizationResultBasedBatchDeleteMutator< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + > { + return this.entityMutatorFactory.forBatchDelete(existingEntities, queryContext); + } + + forBatchUpdate( + existingEntities: readonly TEntity[], + queryContext: EntityQueryContext, + ): AuthorizationResultBasedBatchUpdateMutator< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + > { + return this.entityMutatorFactory.forBatchUpdate(existingEntities, queryContext); + } } diff --git a/packages/entity/src/__tests__/EntityBatchMutator-test.ts b/packages/entity/src/__tests__/EntityBatchMutator-test.ts new file mode 100644 index 000000000..fcc8988c1 --- /dev/null +++ b/packages/entity/src/__tests__/EntityBatchMutator-test.ts @@ -0,0 +1,1347 @@ +import { enforceAsyncResult } from '@expo/results'; +import { describe, expect, it } from '@jest/globals'; +import { anyOfClass, anything, deepEqual, instance, mock, spy, verify, when } from 'ts-mockito'; +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 { + EntityMutationType, + EntityTriggerMutationInfo, + EntityValidatorMutationInfo, +} from '../EntityMutationInfo'; +import { + EntityMutationTrigger, + EntityMutationTriggerConfiguration, + EntityNonTransactionalMutationTrigger, +} from '../EntityMutationTriggerConfiguration'; +import { + EntityMutationValidator, + EntityMutationValidatorConfiguration, +} from '../EntityMutationValidatorConfiguration'; +import { EntityMutatorFactory } from '../EntityMutatorFactory'; +import { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy'; +import { EntityQueryContext, EntityTransactionalQueryContext } from '../EntityQueryContext'; +import { IEntityDatabaseAdapterProvider } from '../IEntityDatabaseAdapterProvider'; +import { ViewerContext } from '../ViewerContext'; +import { EntityDataManager } from '../internal/EntityDataManager'; +import { ReadThroughEntityCache } from '../internal/ReadThroughEntityCache'; +import { IEntityMetricsAdapter } from '../metrics/IEntityMetricsAdapter'; +import { NoOpEntityMetricsAdapter } from '../metrics/NoOpEntityMetricsAdapter'; +import { + SimpleTestEntity, + simpleTestEntityConfiguration, + SimpleTestEntityPrivacyPolicy, + SimpleTestFields, +} from '../utils/__testfixtures__/SimpleTestEntity'; +import { NoCacheStubCacheAdapterProvider } from '../utils/__testfixtures__/StubCacheAdapter'; +import { StubDatabaseAdapter } from '../utils/__testfixtures__/StubDatabaseAdapter'; +import { StubQueryContextProvider } from '../utils/__testfixtures__/StubQueryContextProvider'; +import { + TestEntity, + testEntityConfiguration, + TestEntityPrivacyPolicy, + TestFields, +} from '../utils/__testfixtures__/TestEntity'; + +class TestMutationValidator extends EntityMutationValidator< + TestFields, + 'customIdField', + ViewerContext, + TestEntity, + keyof TestFields +> { + async executeAsync( + _viewerContext: ViewerContext, + _queryContext: EntityQueryContext, + _entity: TestEntity, + _mutationInfo: EntityValidatorMutationInfo< + TestFields, + 'customIdField', + ViewerContext, + TestEntity, + keyof TestFields + >, + ): Promise {} +} + +class TestMutationTrigger extends EntityMutationTrigger< + TestFields, + 'customIdField', + ViewerContext, + TestEntity, + keyof TestFields +> { + async executeAsync( + _viewerContext: ViewerContext, + _queryContext: EntityQueryContext, + _entity: TestEntity, + _mutationInfo: EntityTriggerMutationInfo< + TestFields, + 'customIdField', + ViewerContext, + TestEntity, + keyof TestFields + >, + ): Promise {} +} + +class TestNonTransactionalMutationTrigger extends EntityNonTransactionalMutationTrigger< + TestFields, + 'customIdField', + ViewerContext, + TestEntity, + keyof TestFields +> { + async executeAsync(_viewerContext: ViewerContext, _entity: TestEntity): Promise {} +} + +const createEntityMutatorFactory = ( + existingObjects: TestFields[], +): { + privacyPolicy: TestEntityPrivacyPolicy; + entityLoaderFactory: EntityLoaderFactory< + TestFields, + 'customIdField', + ViewerContext, + TestEntity, + TestEntityPrivacyPolicy, + keyof TestFields + >; + entityMutatorFactory: EntityMutatorFactory< + TestFields, + 'customIdField', + ViewerContext, + TestEntity, + TestEntityPrivacyPolicy + >; + metricsAdapter: IEntityMetricsAdapter; + mutationValidators: EntityMutationValidatorConfiguration< + TestFields, + 'customIdField', + ViewerContext, + TestEntity, + keyof TestFields + >; + mutationTriggers: EntityMutationTriggerConfiguration< + TestFields, + 'customIdField', + ViewerContext, + TestEntity, + keyof TestFields + >; +} => { + const mutationValidators: EntityMutationValidatorConfiguration< + TestFields, + 'customIdField', + ViewerContext, + TestEntity, + keyof TestFields + > = { + beforeCreateAndUpdate: [new TestMutationValidator()], + beforeDelete: [new TestMutationValidator()], + }; + const mutationTriggers: EntityMutationTriggerConfiguration< + TestFields, + 'customIdField', + ViewerContext, + TestEntity, + keyof TestFields + > = { + beforeCreate: [new TestMutationTrigger()], + afterCreate: [new TestMutationTrigger()], + beforeUpdate: [new TestMutationTrigger()], + afterUpdate: [new TestMutationTrigger()], + beforeDelete: [new TestMutationTrigger()], + afterDelete: [new TestMutationTrigger()], + beforeAll: [new TestMutationTrigger()], + afterAll: [new TestMutationTrigger()], + afterCommit: [new TestNonTransactionalMutationTrigger()], + }; + const databaseAdapter = new StubDatabaseAdapter( + testEntityConfiguration, + StubDatabaseAdapter.convertFieldObjectsToDataStore( + testEntityConfiguration, + new Map([[testEntityConfiguration.tableName, existingObjects]]), + ), + ); + const customStubDatabaseAdapterProvider: IEntityDatabaseAdapterProvider = { + getDatabaseAdapter, TIDField extends keyof TFields>( + _entityConfiguration: EntityConfiguration, + ): EntityDatabaseAdapter { + return databaseAdapter as any as EntityDatabaseAdapter; + }, + }; + const metricsAdapter = new NoOpEntityMetricsAdapter(); + const cacheAdapterProvider = new NoCacheStubCacheAdapterProvider(); + const cacheAdapter = cacheAdapterProvider.getCacheAdapter(testEntityConfiguration); + const entityCache = new ReadThroughEntityCache( + testEntityConfiguration, + cacheAdapter, + ); + + const queryContextProvider = new StubQueryContextProvider(); + const companionProvider = new EntityCompanionProvider( + metricsAdapter, + new Map([ + [ + 'postgres', + { + adapterProvider: customStubDatabaseAdapterProvider, + queryContextProvider, + }, + ], + ]), + new Map([ + [ + 'redis', + { + cacheAdapterProvider, + }, + ], + ]), + ); + + const dataManager = new EntityDataManager( + databaseAdapter, + entityCache, + queryContextProvider, + metricsAdapter, + TestEntity.name, + ); + const entityLoaderFactory = new EntityLoaderFactory( + companionProvider.getCompanionForEntity(TestEntity), + dataManager, + metricsAdapter, + ); + const entityMutatorFactory = new EntityMutatorFactory( + companionProvider, + testEntityConfiguration, + TestEntity, + companionProvider.getCompanionForEntity(TestEntity).privacyPolicy, + mutationValidators, + mutationTriggers, + entityLoaderFactory, + databaseAdapter, + metricsAdapter, + ); + return { + privacyPolicy: companionProvider.getCompanionForEntity(TestEntity).privacyPolicy, + entityLoaderFactory, + entityMutatorFactory, + metricsAdapter, + mutationValidators, + mutationTriggers, + }; +}; + +describe('Batch Entity Mutators', () => { + describe('forBatchCreate', () => { + it('creates multiple entities with correct fields', async () => { + const viewerContext = mock(); + const queryContext = new StubQueryContextProvider().getQueryContext(); + + const { entityMutatorFactory } = createEntityMutatorFactory([]); + const entities = await enforceAsyncResult( + entityMutatorFactory + .forBatchCreate(viewerContext, queryContext, [ + { + stringField: 'hello', + testIndexedField: '1', + intField: 1, + dateField: new Date(), + nullableField: null, + }, + { + stringField: 'world', + testIndexedField: '2', + intField: 2, + dateField: new Date(), + nullableField: null, + }, + ]) + .createAsync(), + ); + + expect(entities).toHaveLength(2); + expect(entities[0]!.getField('stringField')).toBe('hello'); + expect(entities[1]!.getField('stringField')).toBe('world'); + }); + + it('returns empty array for empty input with no DB calls', async () => { + const viewerContext = mock(); + const queryContext = new StubQueryContextProvider().getQueryContext(); + + const { entityMutatorFactory } = createEntityMutatorFactory([]); + const result = await entityMutatorFactory + .forBatchCreate(viewerContext, queryContext, []) + .createAsync(); + + expect(result.ok).toBe(true); + expect(result.enforceValue()).toHaveLength(0); + }); + + it('aborts entire batch on first auth failure', async () => { + const entityCompanionProviderMock = mock(EntityCompanionProvider); + when(entityCompanionProviderMock.getCompanionForEntity(SimpleTestEntity)).thenReturn({ + entityCompanionDefinition: SimpleTestEntity.defineCompanionDefinition(), + } as any); + const entityCompanionProvider = instance(entityCompanionProviderMock); + + const viewerContext = instance(mock(ViewerContext)); + const queryContext = new StubQueryContextProvider().getQueryContext(); + const privacyPolicyMock = mock(SimpleTestEntityPrivacyPolicy); + const databaseAdapter = instance(mock>()); + const metricsAdapter = instance(mock()); + + const fakeEntity = new SimpleTestEntity({ + viewerContext, + id: '00000000-0000-0000-0000-000000000000', + selectedFields: { id: '00000000-0000-0000-0000-000000000000' }, + databaseFields: { id: '00000000-0000-0000-0000-000000000000' }, + }); + + const entityLoaderMock = mock< + AuthorizationResultBasedEntityLoader< + SimpleTestFields, + 'id', + ViewerContext, + SimpleTestEntity, + SimpleTestEntityPrivacyPolicy, + keyof SimpleTestFields + > + >(AuthorizationResultBasedEntityLoader); + const entityConstructionUtilsMock = + mock< + EntityConstructionUtils< + SimpleTestFields, + 'id', + ViewerContext, + SimpleTestEntity, + SimpleTestEntityPrivacyPolicy, + keyof SimpleTestFields + > + >(EntityConstructionUtils); + when(entityConstructionUtilsMock.constructEntity(anything())).thenReturn(fakeEntity); + when(entityLoaderMock.constructionUtils).thenReturn(instance(entityConstructionUtilsMock)); + const entityLoader = instance(entityLoaderMock); + + const entityLoaderFactoryMock = + mock< + EntityLoaderFactory< + SimpleTestFields, + 'id', + ViewerContext, + SimpleTestEntity, + SimpleTestEntityPrivacyPolicy, + keyof SimpleTestFields + > + >(EntityLoaderFactory); + when( + entityLoaderFactoryMock.forLoad( + viewerContext, + anyOfClass(EntityTransactionalQueryContext), + anything(), + ), + ).thenReturn(entityLoader); + const entityLoaderFactory = instance(entityLoaderFactoryMock); + + const rejectionError = new Error('not authorized'); + when( + privacyPolicyMock.authorizeCreateAsync( + viewerContext, + anyOfClass(EntityTransactionalQueryContext), + anything(), + anyOfClass(SimpleTestEntity), + anything(), + ), + ).thenReject(rejectionError); + + const entityMutatorFactory = new EntityMutatorFactory( + entityCompanionProvider, + simpleTestEntityConfiguration, + SimpleTestEntity, + instance(privacyPolicyMock), + {}, + {}, + entityLoaderFactory, + databaseAdapter, + metricsAdapter, + ); + + const entityCreateResult = await entityMutatorFactory + .forBatchCreate(viewerContext, queryContext, [{}, {}]) + .createAsync(); + expect(entityCreateResult.ok).toBe(false); + expect(entityCreateResult.reason).toEqual(rejectionError); + }); + + it('throws on field validation failure', async () => { + const viewerContext = mock(); + const queryContext = new StubQueryContextProvider().getQueryContext(); + + const { entityMutatorFactory } = createEntityMutatorFactory([]); + + await expect( + entityMutatorFactory + .forBatchCreate(viewerContext, queryContext, [{ stringField: 10 as any }]) + .createAsync(), + ).rejects.toThrow('Entity field not valid: TestEntity (stringField = 10)'); + }); + + it('executes triggers per-entity', async () => { + const viewerContext = mock(); + const queryContext = new StubQueryContextProvider().getQueryContext(); + + const { mutationTriggers, entityMutatorFactory } = createEntityMutatorFactory([]); + + const beforeCreateSpy = spy(mutationTriggers.beforeCreate![0]!); + const afterCreateSpy = spy(mutationTriggers.afterCreate![0]!); + const beforeAllSpy = spy(mutationTriggers.beforeAll![0]!); + const afterAllSpy = spy(mutationTriggers.afterAll![0]!); + const afterCommitSpy = spy(mutationTriggers.afterCommit![0]!); + + await enforceAsyncResult( + entityMutatorFactory + .forBatchCreate(viewerContext, queryContext, [ + { + stringField: 'a', + testIndexedField: '1', + intField: 1, + dateField: new Date(), + nullableField: null, + }, + { + stringField: 'b', + testIndexedField: '2', + intField: 2, + dateField: new Date(), + nullableField: null, + }, + ]) + .createAsync(), + ); + + verify( + beforeCreateSpy.executeAsync( + viewerContext, + anyOfClass(EntityTransactionalQueryContext), + anyOfClass(TestEntity), + deepEqual({ type: EntityMutationType.CREATE }), + ), + ).twice(); + + verify( + afterCreateSpy.executeAsync( + viewerContext, + anyOfClass(EntityTransactionalQueryContext), + anyOfClass(TestEntity), + deepEqual({ type: EntityMutationType.CREATE }), + ), + ).twice(); + + verify( + beforeAllSpy.executeAsync( + viewerContext, + anyOfClass(EntityTransactionalQueryContext), + anyOfClass(TestEntity), + deepEqual({ type: EntityMutationType.CREATE }), + ), + ).twice(); + + verify( + afterAllSpy.executeAsync( + viewerContext, + anyOfClass(EntityTransactionalQueryContext), + anyOfClass(TestEntity), + deepEqual({ type: EntityMutationType.CREATE }), + ), + ).twice(); + + verify( + afterCommitSpy.executeAsync( + viewerContext, + anyOfClass(TestEntity), + deepEqual({ type: EntityMutationType.CREATE }), + ), + ).twice(); + }); + + it('executes validators per-entity', async () => { + const viewerContext = mock(); + const queryContext = new StubQueryContextProvider().getQueryContext(); + + const { mutationValidators, entityMutatorFactory } = createEntityMutatorFactory([]); + + const beforeCreateAndUpdateSpy = spy(mutationValidators.beforeCreateAndUpdate![0]!); + + await enforceAsyncResult( + entityMutatorFactory + .forBatchCreate(viewerContext, queryContext, [ + { + stringField: 'a', + testIndexedField: '1', + intField: 1, + dateField: new Date(), + nullableField: null, + }, + { + stringField: 'b', + testIndexedField: '2', + intField: 2, + dateField: new Date(), + nullableField: null, + }, + ]) + .createAsync(), + ); + + verify( + beforeCreateAndUpdateSpy.executeAsync( + viewerContext, + anyOfClass(EntityTransactionalQueryContext), + anyOfClass(TestEntity), + deepEqual({ type: EntityMutationType.CREATE }), + ), + ).twice(); + }); + }); + + describe('forBatchUpdate', () => { + it('updates multiple entities with same field changes', async () => { + const viewerContext = mock(); + const privacyPolicyEvaluationContext = + instance( + mock< + EntityPrivacyPolicyEvaluationContext< + TestFields, + 'customIdField', + ViewerContext, + TestEntity, + keyof TestFields + > + >(), + ); + const queryContext = new StubQueryContextProvider().getQueryContext(); + + const id1 = uuidv4(); + const id2 = uuidv4(); + const { entityMutatorFactory, entityLoaderFactory } = createEntityMutatorFactory([ + { + customIdField: id1, + stringField: 'original', + testIndexedField: '1', + intField: 1, + dateField: new Date(), + nullableField: null, + }, + { + customIdField: id2, + stringField: 'original', + testIndexedField: '2', + intField: 2, + dateField: new Date(), + nullableField: null, + }, + ]); + + const entity1 = await enforceAsyncResult( + entityLoaderFactory + .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .loadByIDAsync(id1), + ); + const entity2 = await enforceAsyncResult( + entityLoaderFactory + .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .loadByIDAsync(id2), + ); + + const updatedEntities = await enforceAsyncResult( + entityMutatorFactory + .forBatchUpdate([entity1, entity2], queryContext) + .setField('stringField', 'updated') + .updateAsync(), + ); + + expect(updatedEntities).toHaveLength(2); + expect(updatedEntities[0]!.getField('stringField')).toBe('updated'); + expect(updatedEntities[1]!.getField('stringField')).toBe('updated'); + // Verify IDs are preserved and in order + expect(updatedEntities[0]!.getID()).toBe(id1); + expect(updatedEntities[1]!.getID()).toBe(id2); + }); + + it('returns empty array for empty entity list', async () => { + const queryContext = new StubQueryContextProvider().getQueryContext(); + + const { entityMutatorFactory } = createEntityMutatorFactory([]); + const result = await entityMutatorFactory + .forBatchUpdate([], queryContext) + .setField('stringField', 'updated') + .updateAsync(); + + expect(result.ok).toBe(true); + expect(result.enforceValue()).toHaveLength(0); + }); + + it('aborts entire batch on first auth failure', async () => { + const entityCompanionProviderMock = mock(EntityCompanionProvider); + when(entityCompanionProviderMock.getCompanionForEntity(SimpleTestEntity)).thenReturn({ + entityCompanionDefinition: SimpleTestEntity.defineCompanionDefinition(), + } as any); + const entityCompanionProvider = instance(entityCompanionProviderMock); + + const viewerContext = instance(mock(ViewerContext)); + const queryContext = new StubQueryContextProvider().getQueryContext(); + const privacyPolicyMock = mock(SimpleTestEntityPrivacyPolicy); + const databaseAdapter = instance(mock>()); + const metricsAdapter = instance(mock()); + + const id1 = uuidv4(); + const fakeEntity1 = new SimpleTestEntity({ + viewerContext, + id: id1, + selectedFields: { id: id1 }, + databaseFields: { id: id1 }, + }); + + const id2 = uuidv4(); + const fakeEntity2 = new SimpleTestEntity({ + viewerContext, + id: id2, + selectedFields: { id: id2 }, + databaseFields: { id: id2 }, + }); + + const entityLoaderMock = mock< + AuthorizationResultBasedEntityLoader< + SimpleTestFields, + 'id', + ViewerContext, + SimpleTestEntity, + SimpleTestEntityPrivacyPolicy, + keyof SimpleTestFields + > + >(AuthorizationResultBasedEntityLoader); + const entityConstructionUtilsMock = + mock< + EntityConstructionUtils< + SimpleTestFields, + 'id', + ViewerContext, + SimpleTestEntity, + SimpleTestEntityPrivacyPolicy, + keyof SimpleTestFields + > + >(EntityConstructionUtils); + when(entityConstructionUtilsMock.constructEntity(anything())).thenReturn(fakeEntity1); + when(entityLoaderMock.constructionUtils).thenReturn(instance(entityConstructionUtilsMock)); + const entityLoader = instance(entityLoaderMock); + + const entityLoaderFactoryMock = + mock< + EntityLoaderFactory< + SimpleTestFields, + 'id', + ViewerContext, + SimpleTestEntity, + SimpleTestEntityPrivacyPolicy, + keyof SimpleTestFields + > + >(EntityLoaderFactory); + when( + entityLoaderFactoryMock.forLoad( + viewerContext, + anyOfClass(EntityTransactionalQueryContext), + anything(), + ), + ).thenReturn(entityLoader); + const entityLoaderFactory = instance(entityLoaderFactoryMock); + + const rejectionError = new Error('not authorized'); + when( + privacyPolicyMock.authorizeUpdateAsync( + viewerContext, + anyOfClass(EntityTransactionalQueryContext), + anything(), + anyOfClass(SimpleTestEntity), + anything(), + ), + ).thenReject(rejectionError); + + const entityMutatorFactory = new EntityMutatorFactory( + entityCompanionProvider, + simpleTestEntityConfiguration, + SimpleTestEntity, + instance(privacyPolicyMock), + {}, + {}, + entityLoaderFactory, + databaseAdapter, + metricsAdapter, + ); + + const entityUpdateResult = await entityMutatorFactory + .forBatchUpdate([fakeEntity1, fakeEntity2], queryContext) + .updateAsync(); + expect(entityUpdateResult.ok).toBe(false); + expect(entityUpdateResult.reason).toEqual(rejectionError); + }); + + it('throws when id field is modified', async () => { + const viewerContext = mock(); + const privacyPolicyEvaluationContext = + instance( + mock< + EntityPrivacyPolicyEvaluationContext< + TestFields, + 'customIdField', + ViewerContext, + TestEntity, + keyof TestFields + > + >(), + ); + const queryContext = new StubQueryContextProvider().getQueryContext(); + + const id1 = uuidv4(); + const { entityMutatorFactory, entityLoaderFactory } = createEntityMutatorFactory([ + { + customIdField: id1, + stringField: 'huh', + testIndexedField: '1', + intField: 1, + dateField: new Date(), + nullableField: null, + }, + ]); + + const entity = await enforceAsyncResult( + entityLoaderFactory + .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .loadByIDAsync(id1), + ); + + await expect( + enforceAsyncResult( + entityMutatorFactory + .forBatchUpdate([entity], queryContext) + .setField('customIdField', uuidv4()) + .updateAsync(), + ), + ).rejects.toThrow('id field updates not supported: (entityClass = TestEntity)'); + }); + + it('executes triggers per-entity with correct previousValue', async () => { + const viewerContext = mock(); + const privacyPolicyEvaluationContext = + instance( + mock< + EntityPrivacyPolicyEvaluationContext< + TestFields, + 'customIdField', + ViewerContext, + TestEntity, + keyof TestFields + > + >(), + ); + const queryContext = new StubQueryContextProvider().getQueryContext(); + + const id1 = uuidv4(); + const id2 = uuidv4(); + const { mutationTriggers, entityMutatorFactory, entityLoaderFactory } = + createEntityMutatorFactory([ + { + customIdField: id1, + stringField: 'a', + testIndexedField: '1', + intField: 1, + dateField: new Date(), + nullableField: null, + }, + { + customIdField: id2, + stringField: 'b', + testIndexedField: '2', + intField: 2, + dateField: new Date(), + nullableField: null, + }, + ]); + + const beforeUpdateSpy = spy(mutationTriggers.beforeUpdate![0]!); + const afterUpdateSpy = spy(mutationTriggers.afterUpdate![0]!); + const beforeAllSpy = spy(mutationTriggers.beforeAll![0]!); + const afterAllSpy = spy(mutationTriggers.afterAll![0]!); + const afterCommitSpy = spy(mutationTriggers.afterCommit![0]!); + + const entity1 = await enforceAsyncResult( + entityLoaderFactory + .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .loadByIDAsync(id1), + ); + const entity2 = await enforceAsyncResult( + entityLoaderFactory + .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .loadByIDAsync(id2), + ); + + await enforceAsyncResult( + entityMutatorFactory + .forBatchUpdate([entity1, entity2], queryContext) + .setField('stringField', 'updated') + .updateAsync(), + ); + + // Each trigger should fire twice (once per entity) + verify( + beforeUpdateSpy.executeAsync( + viewerContext, + anyOfClass(EntityTransactionalQueryContext), + anyOfClass(TestEntity), + anything(), + ), + ).twice(); + + verify( + afterUpdateSpy.executeAsync( + viewerContext, + anyOfClass(EntityTransactionalQueryContext), + anyOfClass(TestEntity), + anything(), + ), + ).twice(); + + verify( + beforeAllSpy.executeAsync( + viewerContext, + anyOfClass(EntityTransactionalQueryContext), + anyOfClass(TestEntity), + anything(), + ), + ).twice(); + + verify( + afterAllSpy.executeAsync( + viewerContext, + anyOfClass(EntityTransactionalQueryContext), + anyOfClass(TestEntity), + anything(), + ), + ).twice(); + + verify( + afterCommitSpy.executeAsync(viewerContext, anyOfClass(TestEntity), anything()), + ).twice(); + }); + + it('executes validators per-entity', async () => { + const viewerContext = mock(); + const privacyPolicyEvaluationContext = + instance( + mock< + EntityPrivacyPolicyEvaluationContext< + TestFields, + 'customIdField', + ViewerContext, + TestEntity, + keyof TestFields + > + >(), + ); + const queryContext = new StubQueryContextProvider().getQueryContext(); + + const id1 = uuidv4(); + const id2 = uuidv4(); + const { mutationValidators, entityMutatorFactory, entityLoaderFactory } = + createEntityMutatorFactory([ + { + customIdField: id1, + stringField: 'a', + testIndexedField: '1', + intField: 1, + dateField: new Date(), + nullableField: null, + }, + { + customIdField: id2, + stringField: 'b', + testIndexedField: '2', + intField: 2, + dateField: new Date(), + nullableField: null, + }, + ]); + + const beforeCreateAndUpdateSpy = spy(mutationValidators.beforeCreateAndUpdate![0]!); + + const entity1 = await enforceAsyncResult( + entityLoaderFactory + .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .loadByIDAsync(id1), + ); + const entity2 = await enforceAsyncResult( + entityLoaderFactory + .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .loadByIDAsync(id2), + ); + + await enforceAsyncResult( + entityMutatorFactory + .forBatchUpdate([entity1, entity2], queryContext) + .setField('stringField', 'updated') + .updateAsync(), + ); + + verify( + beforeCreateAndUpdateSpy.executeAsync( + viewerContext, + anyOfClass(EntityTransactionalQueryContext), + anyOfClass(TestEntity), + anything(), + ), + ).twice(); + }); + + it('result ordering matches input entity ordering', async () => { + const viewerContext = mock(); + const privacyPolicyEvaluationContext = + instance( + mock< + EntityPrivacyPolicyEvaluationContext< + TestFields, + 'customIdField', + ViewerContext, + TestEntity, + keyof TestFields + > + >(), + ); + const queryContext = new StubQueryContextProvider().getQueryContext(); + + const id1 = uuidv4(); + const id2 = uuidv4(); + const id3 = uuidv4(); + const { entityMutatorFactory, entityLoaderFactory } = createEntityMutatorFactory([ + { + customIdField: id1, + stringField: 'first', + testIndexedField: '1', + intField: 1, + dateField: new Date(), + nullableField: null, + }, + { + customIdField: id2, + stringField: 'second', + testIndexedField: '2', + intField: 2, + dateField: new Date(), + nullableField: null, + }, + { + customIdField: id3, + stringField: 'third', + testIndexedField: '3', + intField: 3, + dateField: new Date(), + nullableField: null, + }, + ]); + + const entity1 = await enforceAsyncResult( + entityLoaderFactory + .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .loadByIDAsync(id1), + ); + const entity2 = await enforceAsyncResult( + entityLoaderFactory + .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .loadByIDAsync(id2), + ); + const entity3 = await enforceAsyncResult( + entityLoaderFactory + .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .loadByIDAsync(id3), + ); + + // Pass in reverse order to verify ordering + const updatedEntities = await enforceAsyncResult( + entityMutatorFactory + .forBatchUpdate([entity3, entity1, entity2], queryContext) + .setField('stringField', 'updated') + .updateAsync(), + ); + + expect(updatedEntities).toHaveLength(3); + expect(updatedEntities[0]!.getID()).toBe(id3); + expect(updatedEntities[1]!.getID()).toBe(id1); + expect(updatedEntities[2]!.getID()).toBe(id2); + }); + }); + + describe('forBatchDelete', () => { + it('deletes multiple entities', async () => { + const viewerContext = mock(); + const privacyPolicyEvaluationContext = + instance( + mock< + EntityPrivacyPolicyEvaluationContext< + TestFields, + 'customIdField', + ViewerContext, + TestEntity, + keyof TestFields + > + >(), + ); + const queryContext = new StubQueryContextProvider().getQueryContext(); + + const id1 = uuidv4(); + const id2 = uuidv4(); + const { entityMutatorFactory, entityLoaderFactory } = createEntityMutatorFactory([ + { + customIdField: id1, + stringField: 'hello', + testIndexedField: '1', + intField: 1, + dateField: new Date(), + nullableField: null, + }, + { + customIdField: id2, + stringField: 'world', + testIndexedField: '2', + intField: 2, + dateField: new Date(), + nullableField: null, + }, + ]); + + const entity1 = await enforceAsyncResult( + entityLoaderFactory + .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .loadByIDAsync(id1), + ); + const entity2 = await enforceAsyncResult( + entityLoaderFactory + .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .loadByIDAsync(id2), + ); + + const deleteResult = await entityMutatorFactory + .forBatchDelete([entity1, entity2], queryContext) + .deleteAsync(); + + expect(deleteResult.ok).toBe(true); + + // Verify entities are no longer loadable + const loadResult1 = await entityLoaderFactory + .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .loadByIDAsync(id1); + expect(loadResult1.ok).toBe(false); + + const loadResult2 = await entityLoaderFactory + .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .loadByIDAsync(id2); + expect(loadResult2.ok).toBe(false); + }); + + it('returns void result for successful batch delete', async () => { + const viewerContext = mock(); + const privacyPolicyEvaluationContext = + instance( + mock< + EntityPrivacyPolicyEvaluationContext< + TestFields, + 'customIdField', + ViewerContext, + TestEntity, + keyof TestFields + > + >(), + ); + const queryContext = new StubQueryContextProvider().getQueryContext(); + + const id1 = uuidv4(); + const { entityMutatorFactory, entityLoaderFactory } = createEntityMutatorFactory([ + { + customIdField: id1, + stringField: 'hello', + testIndexedField: '1', + intField: 1, + dateField: new Date(), + nullableField: null, + }, + ]); + + const entity1 = await enforceAsyncResult( + entityLoaderFactory + .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .loadByIDAsync(id1), + ); + + const deleteResult = await entityMutatorFactory + .forBatchDelete([entity1], queryContext) + .deleteAsync(); + + expect(deleteResult.ok).toBe(true); + expect(deleteResult.enforceValue()).toBeUndefined(); + }); + + it('returns void for empty entity list', async () => { + const queryContext = new StubQueryContextProvider().getQueryContext(); + + const { entityMutatorFactory } = createEntityMutatorFactory([]); + const result = await entityMutatorFactory.forBatchDelete([], queryContext).deleteAsync(); + + expect(result.ok).toBe(true); + }); + + it('aborts entire batch on first auth failure', async () => { + const entityCompanionProviderMock = mock(EntityCompanionProvider); + when(entityCompanionProviderMock.getCompanionForEntity(SimpleTestEntity)).thenReturn({ + entityCompanionDefinition: SimpleTestEntity.defineCompanionDefinition(), + } as any); + const entityCompanionProvider = instance(entityCompanionProviderMock); + + const viewerContext = instance(mock(ViewerContext)); + const queryContext = new StubQueryContextProvider().getQueryContext(); + const privacyPolicyMock = mock(SimpleTestEntityPrivacyPolicy); + const databaseAdapter = instance(mock>()); + const metricsAdapter = instance(mock()); + + const id1 = uuidv4(); + const fakeEntity1 = new SimpleTestEntity({ + viewerContext, + id: id1, + selectedFields: { id: id1 }, + databaseFields: { id: id1 }, + }); + + const id2 = uuidv4(); + const fakeEntity2 = new SimpleTestEntity({ + viewerContext, + id: id2, + selectedFields: { id: id2 }, + databaseFields: { id: id2 }, + }); + + const entityLoaderFactoryMock = + mock< + EntityLoaderFactory< + SimpleTestFields, + 'id', + ViewerContext, + SimpleTestEntity, + SimpleTestEntityPrivacyPolicy, + keyof SimpleTestFields + > + >(EntityLoaderFactory); + const entityLoaderFactory = instance(entityLoaderFactoryMock); + + const rejectionError = new Error('not authorized'); + when( + privacyPolicyMock.authorizeDeleteAsync( + viewerContext, + anyOfClass(EntityTransactionalQueryContext), + anything(), + anyOfClass(SimpleTestEntity), + anything(), + ), + ).thenReject(rejectionError); + + const entityMutatorFactory = new EntityMutatorFactory( + entityCompanionProvider, + simpleTestEntityConfiguration, + SimpleTestEntity, + instance(privacyPolicyMock), + {}, + {}, + entityLoaderFactory, + databaseAdapter, + metricsAdapter, + ); + + const entityDeleteResult = await entityMutatorFactory + .forBatchDelete([fakeEntity1, fakeEntity2], queryContext) + .deleteAsync(); + expect(entityDeleteResult.ok).toBe(false); + expect(entityDeleteResult.reason).toEqual(rejectionError); + }); + + it('executes triggers per-entity', async () => { + const viewerContext = mock(); + const privacyPolicyEvaluationContext = + instance( + mock< + EntityPrivacyPolicyEvaluationContext< + TestFields, + 'customIdField', + ViewerContext, + TestEntity, + keyof TestFields + > + >(), + ); + const queryContext = new StubQueryContextProvider().getQueryContext(); + + const id1 = uuidv4(); + const id2 = uuidv4(); + const { mutationTriggers, entityMutatorFactory, entityLoaderFactory } = + createEntityMutatorFactory([ + { + customIdField: id1, + stringField: 'a', + testIndexedField: '1', + intField: 1, + dateField: new Date(), + nullableField: null, + }, + { + customIdField: id2, + stringField: 'b', + testIndexedField: '2', + intField: 2, + dateField: new Date(), + nullableField: null, + }, + ]); + + const beforeDeleteSpy = spy(mutationTriggers.beforeDelete![0]!); + const afterDeleteSpy = spy(mutationTriggers.afterDelete![0]!); + const beforeAllSpy = spy(mutationTriggers.beforeAll![0]!); + const afterAllSpy = spy(mutationTriggers.afterAll![0]!); + const afterCommitSpy = spy(mutationTriggers.afterCommit![0]!); + + const entity1 = await enforceAsyncResult( + entityLoaderFactory + .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .loadByIDAsync(id1), + ); + const entity2 = await enforceAsyncResult( + entityLoaderFactory + .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .loadByIDAsync(id2), + ); + + await enforceAsyncResult( + entityMutatorFactory.forBatchDelete([entity1, entity2], queryContext).deleteAsync(), + ); + + verify( + beforeDeleteSpy.executeAsync( + viewerContext, + anyOfClass(EntityTransactionalQueryContext), + anyOfClass(TestEntity), + deepEqual({ type: EntityMutationType.DELETE, cascadingDeleteCause: null }), + ), + ).twice(); + + verify( + afterDeleteSpy.executeAsync( + viewerContext, + anyOfClass(EntityTransactionalQueryContext), + anyOfClass(TestEntity), + deepEqual({ type: EntityMutationType.DELETE, cascadingDeleteCause: null }), + ), + ).twice(); + + verify( + beforeAllSpy.executeAsync( + viewerContext, + anyOfClass(EntityTransactionalQueryContext), + anyOfClass(TestEntity), + deepEqual({ type: EntityMutationType.DELETE, cascadingDeleteCause: null }), + ), + ).twice(); + + verify( + afterAllSpy.executeAsync( + viewerContext, + anyOfClass(EntityTransactionalQueryContext), + anyOfClass(TestEntity), + deepEqual({ type: EntityMutationType.DELETE, cascadingDeleteCause: null }), + ), + ).twice(); + + verify( + afterCommitSpy.executeAsync( + viewerContext, + anyOfClass(TestEntity), + deepEqual({ type: EntityMutationType.DELETE, cascadingDeleteCause: null }), + ), + ).twice(); + }); + + it('executes validators per-entity', async () => { + const viewerContext = mock(); + const privacyPolicyEvaluationContext = + instance( + mock< + EntityPrivacyPolicyEvaluationContext< + TestFields, + 'customIdField', + ViewerContext, + TestEntity, + keyof TestFields + > + >(), + ); + const queryContext = new StubQueryContextProvider().getQueryContext(); + + const id1 = uuidv4(); + const id2 = uuidv4(); + const { mutationValidators, entityMutatorFactory, entityLoaderFactory } = + createEntityMutatorFactory([ + { + customIdField: id1, + stringField: 'a', + testIndexedField: '1', + intField: 1, + dateField: new Date(), + nullableField: null, + }, + { + customIdField: id2, + stringField: 'b', + testIndexedField: '2', + intField: 2, + dateField: new Date(), + nullableField: null, + }, + ]); + + const beforeDeleteSpy = spy(mutationValidators.beforeDelete![0]!); + + const entity1 = await enforceAsyncResult( + entityLoaderFactory + .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .loadByIDAsync(id1), + ); + const entity2 = await enforceAsyncResult( + entityLoaderFactory + .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .loadByIDAsync(id2), + ); + + await enforceAsyncResult( + entityMutatorFactory.forBatchDelete([entity1, entity2], queryContext).deleteAsync(), + ); + + verify( + beforeDeleteSpy.executeAsync( + viewerContext, + anyOfClass(EntityTransactionalQueryContext), + anyOfClass(TestEntity), + deepEqual({ type: EntityMutationType.DELETE, cascadingDeleteCause: null }), + ), + ).twice(); + }); + }); +}); diff --git a/packages/entity/src/__tests__/EntityDatabaseAdapter-test.ts b/packages/entity/src/__tests__/EntityDatabaseAdapter-test.ts index 0df0815aa..0fd340ad3 100644 --- a/packages/entity/src/__tests__/EntityDatabaseAdapter-test.ts +++ b/packages/entity/src/__tests__/EntityDatabaseAdapter-test.ts @@ -4,6 +4,9 @@ import { instance, mock } from 'ts-mockito'; import { EntityDatabaseAdapter } from '../EntityDatabaseAdapter'; import { EntityQueryContext } from '../EntityQueryContext'; import { + EntityDatabaseAdapterBatchDeleteExcessiveResultError, + EntityDatabaseAdapterBatchInsertMismatchResultError, + EntityDatabaseAdapterBatchUpdateMismatchResultError, EntityDatabaseAdapterEmptyInsertResultError, EntityDatabaseAdapterEmptyUpdateResultError, EntityDatabaseAdapterExcessiveDeleteResultError, @@ -21,6 +24,9 @@ class TestEntityDatabaseAdapter extends EntityDatabaseAdapter { + return this.batchInsertResults; + } + + protected async batchUpdateInternalAsync( + _queryInterface: any, + _tableName: string, + _tableIdField: string, + _ids: readonly any[], + _object: object, + ): Promise { + return this.batchUpdateResults; + } + protected async updateInternalAsync( _queryInterface: any, _tableName: string, @@ -83,6 +116,15 @@ class TestEntityDatabaseAdapter extends EntityDatabaseAdapter { + return this.batchDeleteCount; + } + protected async deleteInternalAsync( _queryInterface: any, _tableName: string, @@ -287,4 +329,135 @@ describe(EntityDatabaseAdapter, () => { ); }); }); + + describe('batchInsertAsync', () => { + it('transforms all objects', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const adapter = new TestEntityDatabaseAdapter({ + batchInsertResults: [ + { string_field: 'hello', number_field: 1 }, + { string_field: 'world', number_field: 2 }, + ], + }); + const result = await adapter.batchInsertAsync(queryContext, [ + { stringField: 'hello', intField: 1 } as any, + { stringField: 'world', intField: 2 } as any, + ]); + expect(result).toEqual([ + { stringField: 'hello', intField: 1 }, + { stringField: 'world', intField: 2 }, + ]); + }); + + it('throws when result count does not match input count', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const adapter = new TestEntityDatabaseAdapter({ + batchInsertResults: [{ string_field: 'hello' }], + }); + await expect( + adapter.batchInsertAsync(queryContext, [ + { stringField: 'hello' } as any, + { stringField: 'world' } as any, + ]), + ).rejects.toThrow(EntityDatabaseAdapterBatchInsertMismatchResultError); + }); + + it('returns empty array for empty input', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const adapter = new TestEntityDatabaseAdapter({}); + const result = await adapter.batchInsertAsync(queryContext, []); + expect(result).toEqual([]); + }); + }); + + describe('batchUpdateAsync', () => { + it('transforms object and results', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const adapter = new TestEntityDatabaseAdapter({ + batchUpdateResults: [ + { custom_id: 'id1', string_field: 'updated' }, + { custom_id: 'id2', string_field: 'updated' }, + ], + }); + const result = await adapter.batchUpdateAsync(queryContext, 'customIdField', ['id1', 'id2'], { + stringField: 'updated', + } as any); + expect(result).toEqual([ + { customIdField: 'id1', stringField: 'updated' }, + { customIdField: 'id2', stringField: 'updated' }, + ]); + }); + + it('throws when result count does not match ids count', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const adapter = new TestEntityDatabaseAdapter({ + batchUpdateResults: [{ custom_id: 'id1', string_field: 'updated' }], + }); + await expect( + adapter.batchUpdateAsync(queryContext, 'customIdField', ['id1', 'id2'], { + stringField: 'updated', + } as any), + ).rejects.toThrow(EntityDatabaseAdapterBatchUpdateMismatchResultError); + }); + + it('re-orders results to match input ID order', async () => { + const queryContext = instance(mock(EntityQueryContext)); + // Results come back in reverse order from what was requested + const adapter = new TestEntityDatabaseAdapter({ + batchUpdateResults: [ + { custom_id: 'id2', string_field: 'second' }, + { custom_id: 'id1', string_field: 'first' }, + ], + }); + const result = await adapter.batchUpdateAsync(queryContext, 'customIdField', ['id1', 'id2'], { + stringField: 'updated', + } as any); + // Should be reordered to match input ID order + expect(result[0]).toEqual({ customIdField: 'id1', stringField: 'first' }); + expect(result[1]).toEqual({ customIdField: 'id2', stringField: 'second' }); + }); + + it('returns empty array for empty ids', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const adapter = new TestEntityDatabaseAdapter({}); + const result = await adapter.batchUpdateAsync(queryContext, 'customIdField', [], { + stringField: 'updated', + } as any); + expect(result).toEqual([]); + }); + }); + + describe('batchDeleteAsync', () => { + it('throws when deleted count exceeds ids count', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const adapter = new TestEntityDatabaseAdapter({ batchDeleteCount: 3 }); + await expect( + adapter.batchDeleteAsync(queryContext, 'customIdField', ['id1', 'id2']), + ).rejects.toThrow(EntityDatabaseAdapterBatchDeleteExcessiveResultError); + }); + + it('returns early for empty ids', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const adapter = new TestEntityDatabaseAdapter({}); + await expect( + adapter.batchDeleteAsync(queryContext, 'customIdField', []), + ).resolves.toBeUndefined(); + }); + + it('succeeds when deleted count matches ids count', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const adapter = new TestEntityDatabaseAdapter({ batchDeleteCount: 2 }); + await expect( + adapter.batchDeleteAsync(queryContext, 'customIdField', ['id1', 'id2']), + ).resolves.toBeUndefined(); + }); + + it('succeeds when deleted count is less than ids count', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const adapter = new TestEntityDatabaseAdapter({ batchDeleteCount: 1 }); + await expect( + adapter.batchDeleteAsync(queryContext, 'customIdField', ['id1', 'id2']), + ).resolves.toBeUndefined(); + }); + }); }); diff --git a/packages/entity/src/errors/EntityDatabaseAdapterError.ts b/packages/entity/src/errors/EntityDatabaseAdapterError.ts index 03a0e3319..000ff00da 100644 --- a/packages/entity/src/errors/EntityDatabaseAdapterError.ts +++ b/packages/entity/src/errors/EntityDatabaseAdapterError.ts @@ -232,6 +232,57 @@ export class EntityDatabaseAdapterExcessiveDeleteResultError extends EntityDatab } } +/** + * Thrown when a batch insert operation returns a different number of results than the number of objects inserted. + */ +export class EntityDatabaseAdapterBatchInsertMismatchResultError extends EntityDatabaseAdapterError { + static { + this.prototype.name = 'EntityDatabaseAdapterBatchInsertMismatchResultError'; + } + + get state(): EntityErrorState.PERMANENT { + return EntityErrorState.PERMANENT; + } + + get code(): EntityErrorCode.ERR_ENTITY_DATABASE_ADAPTER_BATCH_INSERT_MISMATCH_RESULT { + return EntityErrorCode.ERR_ENTITY_DATABASE_ADAPTER_BATCH_INSERT_MISMATCH_RESULT; + } +} + +/** + * Thrown when a batch update operation returns a different number of results than the number of IDs provided. + */ +export class EntityDatabaseAdapterBatchUpdateMismatchResultError extends EntityDatabaseAdapterError { + static { + this.prototype.name = 'EntityDatabaseAdapterBatchUpdateMismatchResultError'; + } + + get state(): EntityErrorState.PERMANENT { + return EntityErrorState.PERMANENT; + } + + get code(): EntityErrorCode.ERR_ENTITY_DATABASE_ADAPTER_BATCH_UPDATE_MISMATCH_RESULT { + return EntityErrorCode.ERR_ENTITY_DATABASE_ADAPTER_BATCH_UPDATE_MISMATCH_RESULT; + } +} + +/** + * Thrown when a batch delete operation deletes more rows than the number of IDs provided. + */ +export class EntityDatabaseAdapterBatchDeleteExcessiveResultError extends EntityDatabaseAdapterError { + static { + this.prototype.name = 'EntityDatabaseAdapterBatchDeleteExcessiveResultError'; + } + + get state(): EntityErrorState.PERMANENT { + return EntityErrorState.PERMANENT; + } + + get code(): EntityErrorCode.ERR_ENTITY_DATABASE_ADAPTER_BATCH_DELETE_EXCESSIVE_RESULT { + return EntityErrorCode.ERR_ENTITY_DATABASE_ADAPTER_BATCH_DELETE_EXCESSIVE_RESULT; + } +} + export class EntityDatabaseAdapterPaginationCursorInvalidError extends EntityDatabaseAdapterError { static { this.prototype.name = 'EntityDatabaseAdapterPaginationCursorError'; diff --git a/packages/entity/src/errors/EntityError.ts b/packages/entity/src/errors/EntityError.ts index 24a8d45e0..ff9280588 100644 --- a/packages/entity/src/errors/EntityError.ts +++ b/packages/entity/src/errors/EntityError.ts @@ -28,6 +28,9 @@ export enum EntityErrorCode { ERR_ENTITY_DATABASE_ADAPTER_EXCESSIVE_DELETE_RESULT = 'ERR_ENTITY_DATABASE_ADAPTER_EXCESSIVE_DELETE_RESULT', ERR_ENTITY_CACHE_ADAPTER_TRANSIENT = 'ERR_ENTITY_CACHE_ADAPTER_TRANSIENT', ERR_ENTITY_DATABASE_ADAPTER_PAGINATION_CURSOR_INVALID = 'ERR_ENTITY_DATABASE_ADAPTER_PAGINATION_CURSOR_INVALID', + ERR_ENTITY_DATABASE_ADAPTER_BATCH_INSERT_MISMATCH_RESULT = 'ERR_ENTITY_DATABASE_ADAPTER_BATCH_INSERT_MISMATCH_RESULT', + ERR_ENTITY_DATABASE_ADAPTER_BATCH_UPDATE_MISMATCH_RESULT = 'ERR_ENTITY_DATABASE_ADAPTER_BATCH_UPDATE_MISMATCH_RESULT', + ERR_ENTITY_DATABASE_ADAPTER_BATCH_DELETE_EXCESSIVE_RESULT = 'ERR_ENTITY_DATABASE_ADAPTER_BATCH_DELETE_EXCESSIVE_RESULT', } /** diff --git a/packages/entity/src/index.ts b/packages/entity/src/index.ts index 172ab82b5..a3001a6d3 100644 --- a/packages/entity/src/index.ts +++ b/packages/entity/src/index.ts @@ -10,12 +10,18 @@ export * from './AuthorizationResultBasedEntityMutator'; export * from './ComposedEntityCacheAdapter'; export * from './ComposedSecondaryEntityCache'; export * from './EnforcingEntityAssociationLoader'; +export * from './EnforcingEntityBatchCreator'; +export * from './EnforcingEntityBatchDeleter'; +export * from './EnforcingEntityBatchUpdater'; export * from './EnforcingEntityCreator'; export * from './EnforcingEntityDeleter'; export * from './EnforcingEntityLoader'; export * from './EnforcingEntityUpdater'; export * from './Entity'; export * from './EntityAssociationLoader'; +export * from './EntityBatchCreator'; +export * from './EntityBatchDeleter'; +export * from './EntityBatchUpdater'; export * from './EntityCompanion'; export * from './EntityCompanionProvider'; export * from './EntityConfiguration'; diff --git a/packages/entity/src/utils/__testfixtures__/StubDatabaseAdapter.ts b/packages/entity/src/utils/__testfixtures__/StubDatabaseAdapter.ts index e7759e5ac..44d8e287e 100644 --- a/packages/entity/src/utils/__testfixtures__/StubDatabaseAdapter.ts +++ b/packages/entity/src/utils/__testfixtures__/StubDatabaseAdapter.ts @@ -110,6 +110,56 @@ export class StubDatabaseAdapter< } } + protected async batchInsertInternalAsync( + _queryInterface: any, + tableName: string, + objects: readonly object[], + ): Promise { + const objectCollection = this.getObjectCollectionForTable(tableName); + + const idField = getDatabaseFieldForEntityField( + this.entityConfiguration2, + this.entityConfiguration2.idField, + ); + const insertedObjects: object[] = []; + for (const object of objects) { + const objectToInsert = { + [idField]: this.generateRandomID(), + ...object, + }; + objectCollection.push(objectToInsert); + insertedObjects.push(objectToInsert); + } + return insertedObjects; + } + + protected async batchUpdateInternalAsync( + _queryInterface: any, + tableName: string, + tableIdField: string, + ids: readonly any[], + object: object, + ): Promise { + if (Object.keys(object).length === 0) { + throw new Error(`Empty batch update (${tableIdField} IN (${ids.join(', ')}))`); + } + + const objectCollection = this.getObjectCollectionForTable(tableName); + const updatedObjects: object[] = []; + + for (const id of ids) { + const objectIndex = objectCollection.findIndex((obj) => obj[tableIdField] === id); + if (objectIndex >= 0) { + objectCollection[objectIndex] = { + ...objectCollection[objectIndex], + ...object, + }; + updatedObjects.push(objectCollection[objectIndex]); + } + } + return updatedObjects; + } + protected async insertInternalAsync( _queryInterface: any, tableName: string, @@ -160,6 +210,25 @@ export class StubDatabaseAdapter< return [objectCollection[objectIndex]]; } + protected async batchDeleteInternalAsync( + _queryInterface: any, + tableName: string, + tableIdField: string, + ids: readonly any[], + ): Promise { + const objectCollection = this.getObjectCollectionForTable(tableName); + let count = 0; + + for (const id of ids) { + const objectIndex = objectCollection.findIndex((obj) => obj[tableIdField] === id); + if (objectIndex >= 0) { + objectCollection.splice(objectIndex, 1); + count++; + } + } + return count; + } + protected async deleteInternalAsync( _queryInterface: any, tableName: string,