From 3ac928cc1652cc3ae46b47466ffef916541bc015 Mon Sep 17 00:00:00 2001 From: Will Schurman Date: Wed, 19 Apr 2023 13:47:00 -0700 Subject: [PATCH 1/2] feat: add ability to specify knex transaction config --- .../src/PostgresEntityQueryContextProvider.ts | 58 ++++++++++++++++--- .../PostgresEntityIntegration-test.ts | 39 +++++++++++++ .../adapters/InMemoryQueryContextProvider.ts | 11 ++-- packages/entity/src/EntityQueryContext.ts | 30 ++++++++-- .../entity/src/EntityQueryContextProvider.ts | 23 +++++--- packages/entity/src/ViewerContext.ts | 11 +++- .../src/__tests__/EntityQueryContext-test.ts | 24 +++++++- .../utils/testing/StubQueryContextProvider.ts | 10 ++-- 8 files changed, 174 insertions(+), 32 deletions(-) diff --git a/packages/entity-database-adapter-knex/src/PostgresEntityQueryContextProvider.ts b/packages/entity-database-adapter-knex/src/PostgresEntityQueryContextProvider.ts index 0cde1e34..ff9ecf8e 100644 --- a/packages/entity-database-adapter-knex/src/PostgresEntityQueryContextProvider.ts +++ b/packages/entity-database-adapter-knex/src/PostgresEntityQueryContextProvider.ts @@ -1,4 +1,8 @@ -import { EntityQueryContextProvider } from '@expo/entity'; +import { + EntityQueryContextProvider, + TransactionConfig, + TransactionIsolationLevel, +} from '@expo/entity'; import { Knex } from 'knex'; /** @@ -13,15 +17,55 @@ export default class PostgresEntityQueryContextProvider extends EntityQueryConte return this.knexInstance; } - protected createTransactionRunner(): ( - transactionScope: (trx: any) => Promise - ) => Promise { - return (transactionScope) => this.knexInstance.transaction(transactionScope); + protected createTransactionRunner( + transactionConfig?: TransactionConfig + ): (transactionScope: (trx: any) => Promise) => Promise { + return (transactionScope) => + this.knexInstance.transaction( + transactionScope, + transactionConfig + ? PostgresEntityQueryContextProvider.convertTransactionConfig(transactionConfig) + : undefined + ); } protected createNestedTransactionRunner( - outerQueryInterface: any + outerQueryInterface: any, + transactionConfig?: TransactionConfig ): (transactionScope: (queryInterface: any) => Promise) => Promise { - return (transactionScope) => (outerQueryInterface as Knex).transaction(transactionScope); + return (transactionScope) => + (outerQueryInterface as Knex).transaction( + transactionScope, + transactionConfig + ? PostgresEntityQueryContextProvider.convertTransactionConfig(transactionConfig) + : undefined + ); + } + + private static convertTransactionConfig( + transactionConfig: TransactionConfig + ): Knex.TransactionConfig { + const convertIsolationLevel = ( + isolationLevel: TransactionIsolationLevel + ): Knex.IsolationLevels => { + switch (isolationLevel) { + case TransactionIsolationLevel.READ_UNCOMMITTED: + return 'read uncommitted'; + case TransactionIsolationLevel.READ_COMMITTED: + return 'read committed'; + case TransactionIsolationLevel.SNAPSHOT: + return 'snapshot'; + case TransactionIsolationLevel.REPEATABLE_READ: + return 'repeatable read'; + case TransactionIsolationLevel.SERIALIZABLE: + return 'serializable'; + } + }; + + return { + ...(transactionConfig.isolationLevel + ? { isolationLevel: convertIsolationLevel(transactionConfig.isolationLevel) } + : {}), + }; } } 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 f468c436..3af8f407 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 @@ -3,6 +3,7 @@ import { createUnitTestEntityCompanionProvider, enforceResultsAsync, ViewerContext, + TransactionIsolationLevel, } from '@expo/entity'; import { enforceAsyncResult } from '@expo/results'; import { knex, Knex } from 'knex'; @@ -111,6 +112,44 @@ describe('postgres entity integration', () => { expect(entities).toHaveLength(1); }); + it('passes transaction config into transactions', async () => { + const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + const firstEntity = await enforceAsyncResult( + PostgresTestEntity.creator(vc1).setField('name', 'hello').createAsync() + ); + + const loadAndUpdateAsync = async (newName: string): Promise<{ error?: Error }> => { + try { + await vc1.runInTransactionForDatabaseAdaptorFlavorAsync( + 'postgres', + async (queryContext) => { + const entity = await PostgresTestEntity.loader(vc1, queryContext) + .enforcing() + .loadByIDAsync(firstEntity.getID()); + await PostgresTestEntity.updater(entity, queryContext) + .setField('name', newName) + .enforceUpdateAsync(); + }, + { isolationLevel: TransactionIsolationLevel.SERIALIZABLE } + ); + return {}; + } catch (e) { + return { error: e as Error }; + } + }; + + // do some parallel updates to trigger serializable error in at least some of them + const results = await Promise.all([ + loadAndUpdateAsync('hello2'), + loadAndUpdateAsync('hello3'), + loadAndUpdateAsync('hello4'), + loadAndUpdateAsync('hello5'), + ]); + + expect(results.filter((r) => (r.error as any)?.cause?.code === '40001').length > 0).toBe(true); + }); + describe('JSON fields', () => { it('supports both types of array fields', async () => { const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); diff --git a/packages/entity-example/src/adapters/InMemoryQueryContextProvider.ts b/packages/entity-example/src/adapters/InMemoryQueryContextProvider.ts index 9ab525ea..fad10bed 100644 --- a/packages/entity-example/src/adapters/InMemoryQueryContextProvider.ts +++ b/packages/entity-example/src/adapters/InMemoryQueryContextProvider.ts @@ -1,18 +1,19 @@ -import { EntityQueryContextProvider } from '@expo/entity'; +import { EntityQueryContextProvider, TransactionConfig } from '@expo/entity'; export default class InMemoryQueryContextProvider extends EntityQueryContextProvider { protected getQueryInterface(): any { return {}; } - protected createTransactionRunner(): ( - transactionScope: (queryInterface: any) => Promise - ) => Promise { + protected createTransactionRunner( + _transactionConfig?: TransactionConfig + ): (transactionScope: (queryInterface: any) => Promise) => Promise { return (transactionScope) => Promise.resolve(transactionScope({})); } protected createNestedTransactionRunner( - _outerQueryInterface: any + _outerQueryInterface: any, + _transactionConfig?: TransactionConfig ): (transactionScope: (queryInterface: any) => Promise) => Promise { return (transactionScope) => Promise.resolve(transactionScope({})); } diff --git a/packages/entity/src/EntityQueryContext.ts b/packages/entity/src/EntityQueryContext.ts index d4a3f91f..ef6bf212 100644 --- a/packages/entity/src/EntityQueryContext.ts +++ b/packages/entity/src/EntityQueryContext.ts @@ -8,6 +8,18 @@ export type PreCommitCallback = ( ...args: any ) => Promise; +export enum TransactionIsolationLevel { + READ_UNCOMMITTED, + READ_COMMITTED, + SNAPSHOT, + REPEATABLE_READ, + SERIALIZABLE, +} + +export type TransactionConfig = { + isolationLevel?: TransactionIsolationLevel; +}; + /** * Entity framework representation of transactional and non-transactional database * query execution units. @@ -25,7 +37,8 @@ export abstract class EntityQueryContext { } abstract runInTransactionIfNotInTransactionAsync( - transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise + transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise, + transactionConfig?: TransactionConfig ): Promise; } @@ -48,9 +61,13 @@ export class EntityNonTransactionalQueryContext extends EntityQueryContext { } async runInTransactionIfNotInTransactionAsync( - transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise + transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise, + transactionConfig?: TransactionConfig ): Promise { - return await this.entityQueryContextProvider.runInTransactionAsync(transactionScope); + return await this.entityQueryContextProvider.runInTransactionAsync( + transactionScope, + transactionConfig + ); } } @@ -132,8 +149,13 @@ export class EntityTransactionalQueryContext extends EntityQueryContext { } async runInTransactionIfNotInTransactionAsync( - transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise + transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise, + transactionConfig?: TransactionConfig ): Promise { + assert( + transactionConfig === undefined, + 'should not pass transactionConfig to an already created transaction' + ); return await transactionScope(this); } diff --git a/packages/entity/src/EntityQueryContextProvider.ts b/packages/entity/src/EntityQueryContextProvider.ts index c6f94ec2..21af19c0 100644 --- a/packages/entity/src/EntityQueryContextProvider.ts +++ b/packages/entity/src/EntityQueryContextProvider.ts @@ -2,6 +2,7 @@ import { EntityTransactionalQueryContext, EntityNonTransactionalQueryContext, EntityNestedTransactionalQueryContext, + TransactionConfig, } from './EntityQueryContext'; /** @@ -23,12 +24,13 @@ export default abstract class EntityQueryContextProvider { /** * Vend a transaction runner for use in runInTransactionAsync. */ - protected abstract createTransactionRunner(): ( - transactionScope: (queryInterface: any) => Promise - ) => Promise; + protected abstract createTransactionRunner( + transactionConfig?: TransactionConfig + ): (transactionScope: (queryInterface: any) => Promise) => Promise; protected abstract createNestedTransactionRunner( - outerQueryInterface: any + outerQueryInterface: any, + transactionConfig?: TransactionConfig ): (transactionScope: (queryInterface: any) => Promise) => Promise; /** @@ -36,11 +38,12 @@ export default abstract class EntityQueryContextProvider { * @param transactionScope - async callback to execute within the transaction */ async runInTransactionAsync( - transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise + transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise, + transactionConfig?: TransactionConfig ): Promise { const [returnedValue, queryContext] = await this.createTransactionRunner< [T, EntityTransactionalQueryContext] - >()(async (queryInterface) => { + >(transactionConfig)(async (queryInterface) => { const queryContext = new EntityTransactionalQueryContext(queryInterface, this); const result = await transactionScope(queryContext); await queryContext.runPreCommitCallbacksAsync(); @@ -58,11 +61,15 @@ export default abstract class EntityQueryContextProvider { */ async runInNestedTransactionAsync( outerQueryContext: EntityTransactionalQueryContext, - transactionScope: (innerQueryContext: EntityNestedTransactionalQueryContext) => Promise + transactionScope: (innerQueryContext: EntityNestedTransactionalQueryContext) => Promise, + transactionConfig?: TransactionConfig ): Promise { const [returnedValue, innerQueryContext] = await this.createNestedTransactionRunner< [T, EntityNestedTransactionalQueryContext] - >(outerQueryContext.getQueryInterface())(async (innerQueryInterface) => { + >( + outerQueryContext.getQueryInterface(), + transactionConfig + )(async (innerQueryInterface) => { const innerQueryContext = new EntityNestedTransactionalQueryContext( innerQueryInterface, outerQueryContext, diff --git a/packages/entity/src/ViewerContext.ts b/packages/entity/src/ViewerContext.ts index e441dc10..ad1dd593 100644 --- a/packages/entity/src/ViewerContext.ts +++ b/packages/entity/src/ViewerContext.ts @@ -1,7 +1,11 @@ import { IEntityClass } from './Entity'; import EntityCompanionProvider, { DatabaseAdapterFlavor } from './EntityCompanionProvider'; import EntityPrivacyPolicy from './EntityPrivacyPolicy'; -import { EntityQueryContext, EntityTransactionalQueryContext } from './EntityQueryContext'; +import { + EntityQueryContext, + EntityTransactionalQueryContext, + TransactionConfig, +} from './EntityQueryContext'; import ReadonlyEntity from './ReadonlyEntity'; import ViewerScopedEntityCompanion from './ViewerScopedEntityCompanion'; import ViewerScopedEntityCompanionProvider from './ViewerScopedEntityCompanionProvider'; @@ -82,11 +86,12 @@ export default class ViewerContext { */ async runInTransactionForDatabaseAdaptorFlavorAsync( databaseAdaptorFlavor: DatabaseAdapterFlavor, - transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise + transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise, + transactionConfig?: TransactionConfig ): Promise { return await this.entityCompanionProvider .getQueryContextProviderForDatabaseAdaptorFlavor(databaseAdaptorFlavor) .getQueryContext() - .runInTransactionIfNotInTransactionAsync(transactionScope); + .runInTransactionIfNotInTransactionAsync(transactionScope, transactionConfig); } } diff --git a/packages/entity/src/__tests__/EntityQueryContext-test.ts b/packages/entity/src/__tests__/EntityQueryContext-test.ts index 26ec844d..d33258ea 100644 --- a/packages/entity/src/__tests__/EntityQueryContext-test.ts +++ b/packages/entity/src/__tests__/EntityQueryContext-test.ts @@ -1,6 +1,6 @@ import invariant from 'invariant'; -import { EntityQueryContext } from '../EntityQueryContext'; +import { EntityQueryContext, TransactionIsolationLevel } from '../EntityQueryContext'; import ViewerContext from '../ViewerContext'; import { createUnitTestEntityCompanionProvider } from '../utils/testing/createUnitTestEntityCompanionProvider'; @@ -129,4 +129,26 @@ describe(EntityQueryContext, () => { expect(postCommitInvalidationCallback).toHaveBeenCalledTimes(2); }); }); + + describe('transaction config', () => { + it('passes it into the provider', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new ViewerContext(companionProvider); + + const queryContextProvider = + companionProvider.getQueryContextProviderForDatabaseAdaptorFlavor('postgres'); + const queryContextProviderSpy = jest.spyOn(queryContextProvider, 'runInTransactionAsync'); + + const transactionScopeFn = async (): Promise => {}; + const transactionConfig = { isolationLevel: TransactionIsolationLevel.SERIALIZABLE }; + + await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync( + 'postgres', + transactionScopeFn, + transactionConfig + ); + + expect(queryContextProviderSpy).toHaveBeenCalledWith(transactionScopeFn, transactionConfig); + }); + }); }); diff --git a/packages/entity/src/utils/testing/StubQueryContextProvider.ts b/packages/entity/src/utils/testing/StubQueryContextProvider.ts index 45c92977..21afebd7 100644 --- a/packages/entity/src/utils/testing/StubQueryContextProvider.ts +++ b/packages/entity/src/utils/testing/StubQueryContextProvider.ts @@ -1,3 +1,4 @@ +import { TransactionConfig } from '../../EntityQueryContext'; import EntityQueryContextProvider from '../../EntityQueryContextProvider'; export class StubQueryContextProvider extends EntityQueryContextProvider { @@ -5,14 +6,15 @@ export class StubQueryContextProvider extends EntityQueryContextProvider { return {}; } - protected createTransactionRunner(): ( - transactionScope: (queryInterface: any) => Promise - ) => Promise { + protected createTransactionRunner( + _transactionConfig?: TransactionConfig + ): (transactionScope: (queryInterface: any) => Promise) => Promise { return (transactionScope) => Promise.resolve(transactionScope({})); } protected createNestedTransactionRunner( - _outerQueryInterface: any + _outerQueryInterface: any, + _transactionConfig?: TransactionConfig ): (transactionScope: (queryInterface: any) => Promise) => Promise { return (transactionScope) => Promise.resolve(transactionScope({})); } From 8cc37193141d8309f23308dc4c3a9271b0602c07 Mon Sep 17 00:00:00 2001 From: Will Schurman Date: Thu, 20 Apr 2023 17:42:26 -0700 Subject: [PATCH 2/2] Address comments --- .../src/PostgresEntityQueryContextProvider.ts | 15 ++------------- .../src/adapters/InMemoryQueryContextProvider.ts | 3 +-- packages/entity/src/EntityQueryContext.ts | 10 ++++------ packages/entity/src/EntityQueryContextProvider.ts | 11 +++-------- .../internal/__tests__/EntityDataManager-test.ts | 3 ++- .../src/utils/testing/StubQueryContextProvider.ts | 3 +-- 6 files changed, 13 insertions(+), 32 deletions(-) diff --git a/packages/entity-database-adapter-knex/src/PostgresEntityQueryContextProvider.ts b/packages/entity-database-adapter-knex/src/PostgresEntityQueryContextProvider.ts index ff9ecf8e..85fe34ee 100644 --- a/packages/entity-database-adapter-knex/src/PostgresEntityQueryContextProvider.ts +++ b/packages/entity-database-adapter-knex/src/PostgresEntityQueryContextProvider.ts @@ -30,16 +30,9 @@ export default class PostgresEntityQueryContextProvider extends EntityQueryConte } protected createNestedTransactionRunner( - outerQueryInterface: any, - transactionConfig?: TransactionConfig + outerQueryInterface: any ): (transactionScope: (queryInterface: any) => Promise) => Promise { - return (transactionScope) => - (outerQueryInterface as Knex).transaction( - transactionScope, - transactionConfig - ? PostgresEntityQueryContextProvider.convertTransactionConfig(transactionConfig) - : undefined - ); + return (transactionScope) => (outerQueryInterface as Knex).transaction(transactionScope); } private static convertTransactionConfig( @@ -49,12 +42,8 @@ export default class PostgresEntityQueryContextProvider extends EntityQueryConte isolationLevel: TransactionIsolationLevel ): Knex.IsolationLevels => { switch (isolationLevel) { - case TransactionIsolationLevel.READ_UNCOMMITTED: - return 'read uncommitted'; case TransactionIsolationLevel.READ_COMMITTED: return 'read committed'; - case TransactionIsolationLevel.SNAPSHOT: - return 'snapshot'; case TransactionIsolationLevel.REPEATABLE_READ: return 'repeatable read'; case TransactionIsolationLevel.SERIALIZABLE: diff --git a/packages/entity-example/src/adapters/InMemoryQueryContextProvider.ts b/packages/entity-example/src/adapters/InMemoryQueryContextProvider.ts index fad10bed..c03d2558 100644 --- a/packages/entity-example/src/adapters/InMemoryQueryContextProvider.ts +++ b/packages/entity-example/src/adapters/InMemoryQueryContextProvider.ts @@ -12,8 +12,7 @@ export default class InMemoryQueryContextProvider extends EntityQueryContextProv } protected createNestedTransactionRunner( - _outerQueryInterface: any, - _transactionConfig?: TransactionConfig + _outerQueryInterface: any ): (transactionScope: (queryInterface: any) => Promise) => Promise { return (transactionScope) => Promise.resolve(transactionScope({})); } diff --git a/packages/entity/src/EntityQueryContext.ts b/packages/entity/src/EntityQueryContext.ts index ef6bf212..13d8bacd 100644 --- a/packages/entity/src/EntityQueryContext.ts +++ b/packages/entity/src/EntityQueryContext.ts @@ -9,11 +9,9 @@ export type PreCommitCallback = ( ) => Promise; export enum TransactionIsolationLevel { - READ_UNCOMMITTED, - READ_COMMITTED, - SNAPSHOT, - REPEATABLE_READ, - SERIALIZABLE, + READ_COMMITTED = 'READ_COMMITTED', + REPEATABLE_READ = 'REPEATABLE_READ', + SERIALIZABLE = 'SERIALIZABLE', } export type TransactionConfig = { @@ -154,7 +152,7 @@ export class EntityTransactionalQueryContext extends EntityQueryContext { ): Promise { assert( transactionConfig === undefined, - 'should not pass transactionConfig to an already created transaction' + 'Should not pass transactionConfig to a nested transaction' ); return await transactionScope(this); } diff --git a/packages/entity/src/EntityQueryContextProvider.ts b/packages/entity/src/EntityQueryContextProvider.ts index 21af19c0..42740fe9 100644 --- a/packages/entity/src/EntityQueryContextProvider.ts +++ b/packages/entity/src/EntityQueryContextProvider.ts @@ -29,8 +29,7 @@ export default abstract class EntityQueryContextProvider { ): (transactionScope: (queryInterface: any) => Promise) => Promise; protected abstract createNestedTransactionRunner( - outerQueryInterface: any, - transactionConfig?: TransactionConfig + outerQueryInterface: any ): (transactionScope: (queryInterface: any) => Promise) => Promise; /** @@ -61,15 +60,11 @@ export default abstract class EntityQueryContextProvider { */ async runInNestedTransactionAsync( outerQueryContext: EntityTransactionalQueryContext, - transactionScope: (innerQueryContext: EntityNestedTransactionalQueryContext) => Promise, - transactionConfig?: TransactionConfig + transactionScope: (innerQueryContext: EntityNestedTransactionalQueryContext) => Promise ): Promise { const [returnedValue, innerQueryContext] = await this.createNestedTransactionRunner< [T, EntityNestedTransactionalQueryContext] - >( - outerQueryContext.getQueryInterface(), - transactionConfig - )(async (innerQueryInterface) => { + >(outerQueryContext.getQueryInterface())(async (innerQueryInterface) => { const innerQueryContext = new EntityNestedTransactionalQueryContext( innerQueryInterface, outerQueryContext, diff --git a/packages/entity/src/internal/__tests__/EntityDataManager-test.ts b/packages/entity/src/internal/__tests__/EntityDataManager-test.ts index 8222e8d6..12b79770 100644 --- a/packages/entity/src/internal/__tests__/EntityDataManager-test.ts +++ b/packages/entity/src/internal/__tests__/EntityDataManager-test.ts @@ -385,7 +385,8 @@ describe(EntityDataManager, () => { return await entityDataManager.loadManyByFieldEqualingAsync(queryContext, 'customIdField', [ '1', ]); - } + }, + {} ); expect(entityDatas.get('1')).toHaveLength(1); diff --git a/packages/entity/src/utils/testing/StubQueryContextProvider.ts b/packages/entity/src/utils/testing/StubQueryContextProvider.ts index 21afebd7..e81c4919 100644 --- a/packages/entity/src/utils/testing/StubQueryContextProvider.ts +++ b/packages/entity/src/utils/testing/StubQueryContextProvider.ts @@ -13,8 +13,7 @@ export class StubQueryContextProvider extends EntityQueryContextProvider { } protected createNestedTransactionRunner( - _outerQueryInterface: any, - _transactionConfig?: TransactionConfig + _outerQueryInterface: any ): (transactionScope: (queryInterface: any) => Promise) => Promise { return (transactionScope) => Promise.resolve(transactionScope({})); }