Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add ability to specify knex transaction config #207

Merged
merged 2 commits into from
Apr 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { EntityQueryContextProvider } from '@expo/entity';
import {
EntityQueryContextProvider,
TransactionConfig,
TransactionIsolationLevel,
} from '@expo/entity';
import { Knex } from 'knex';

/**
Expand All @@ -13,15 +17,44 @@ export default class PostgresEntityQueryContextProvider extends EntityQueryConte
return this.knexInstance;
}

protected createTransactionRunner<T>(): (
transactionScope: (trx: any) => Promise<T>
) => Promise<T> {
return (transactionScope) => this.knexInstance.transaction(transactionScope);
protected createTransactionRunner<T>(
transactionConfig?: TransactionConfig
): (transactionScope: (trx: any) => Promise<T>) => Promise<T> {
return (transactionScope) =>
this.knexInstance.transaction(
transactionScope,
transactionConfig
? PostgresEntityQueryContextProvider.convertTransactionConfig(transactionConfig)
: undefined
);
}

protected createNestedTransactionRunner<T>(
outerQueryInterface: any
): (transactionScope: (queryInterface: any) => Promise<T>) => Promise<T> {
return (transactionScope) => (outerQueryInterface as Knex).transaction(transactionScope);
}

private static convertTransactionConfig(
transactionConfig: TransactionConfig
): Knex.TransactionConfig {
const convertIsolationLevel = (
isolationLevel: TransactionIsolationLevel
): Knex.IsolationLevels => {
switch (isolationLevel) {
case TransactionIsolationLevel.READ_COMMITTED:
return 'read committed';
case TransactionIsolationLevel.REPEATABLE_READ:
return 'repeatable read';
case TransactionIsolationLevel.SERIALIZABLE:
return 'serializable';
}
};

return {
...(transactionConfig.isolationLevel
? { isolationLevel: convertIsolationLevel(transactionConfig.isolationLevel) }
: {}),
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
createUnitTestEntityCompanionProvider,
enforceResultsAsync,
ViewerContext,
TransactionIsolationLevel,
} from '@expo/entity';
import { enforceAsyncResult } from '@expo/results';
import { knex, Knex } from 'knex';
Expand Down Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { EntityQueryContextProvider } from '@expo/entity';
import { EntityQueryContextProvider, TransactionConfig } from '@expo/entity';

export default class InMemoryQueryContextProvider extends EntityQueryContextProvider {
protected getQueryInterface(): any {
return {};
}

protected createTransactionRunner<T>(): (
transactionScope: (queryInterface: any) => Promise<T>
) => Promise<T> {
protected createTransactionRunner<T>(
_transactionConfig?: TransactionConfig
): (transactionScope: (queryInterface: any) => Promise<T>) => Promise<T> {
return (transactionScope) => Promise.resolve(transactionScope({}));
}

Expand Down
28 changes: 24 additions & 4 deletions packages/entity/src/EntityQueryContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ export type PreCommitCallback = (
...args: any
) => Promise<any>;

export enum TransactionIsolationLevel {
READ_COMMITTED = 'READ_COMMITTED',
REPEATABLE_READ = 'REPEATABLE_READ',
SERIALIZABLE = 'SERIALIZABLE',
}

export type TransactionConfig = {
isolationLevel?: TransactionIsolationLevel;
};

/**
* Entity framework representation of transactional and non-transactional database
* query execution units.
Expand All @@ -25,7 +35,8 @@ export abstract class EntityQueryContext {
}

abstract runInTransactionIfNotInTransactionAsync<T>(
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T>
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T>,
transactionConfig?: TransactionConfig
): Promise<T>;
}

Expand All @@ -48,9 +59,13 @@ export class EntityNonTransactionalQueryContext extends EntityQueryContext {
}

async runInTransactionIfNotInTransactionAsync<T>(
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T>
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T>,
transactionConfig?: TransactionConfig
): Promise<T> {
return await this.entityQueryContextProvider.runInTransactionAsync(transactionScope);
return await this.entityQueryContextProvider.runInTransactionAsync(
transactionScope,
transactionConfig
);
}
}

Expand Down Expand Up @@ -132,8 +147,13 @@ export class EntityTransactionalQueryContext extends EntityQueryContext {
}

async runInTransactionIfNotInTransactionAsync<T>(
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T>
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T>,
transactionConfig?: TransactionConfig
): Promise<T> {
assert(
transactionConfig === undefined,
'Should not pass transactionConfig to a nested transaction'
);
return await transactionScope(this);
}

Expand Down
12 changes: 7 additions & 5 deletions packages/entity/src/EntityQueryContextProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
EntityTransactionalQueryContext,
EntityNonTransactionalQueryContext,
EntityNestedTransactionalQueryContext,
TransactionConfig,
} from './EntityQueryContext';

/**
Expand All @@ -23,9 +24,9 @@ export default abstract class EntityQueryContextProvider {
/**
* Vend a transaction runner for use in runInTransactionAsync.
*/
protected abstract createTransactionRunner<T>(): (
transactionScope: (queryInterface: any) => Promise<T>
) => Promise<T>;
protected abstract createTransactionRunner<T>(
transactionConfig?: TransactionConfig
): (transactionScope: (queryInterface: any) => Promise<T>) => Promise<T>;

protected abstract createNestedTransactionRunner<T>(
outerQueryInterface: any
Expand All @@ -36,11 +37,12 @@ export default abstract class EntityQueryContextProvider {
* @param transactionScope - async callback to execute within the transaction
*/
async runInTransactionAsync<T>(
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T>
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T>,
transactionConfig?: TransactionConfig
wschurman marked this conversation as resolved.
Show resolved Hide resolved
): Promise<T> {
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();
Expand Down
11 changes: 8 additions & 3 deletions packages/entity/src/ViewerContext.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -82,11 +86,12 @@ export default class ViewerContext {
*/
async runInTransactionForDatabaseAdaptorFlavorAsync<TResult>(
databaseAdaptorFlavor: DatabaseAdapterFlavor,
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<TResult>
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<TResult>,
transactionConfig?: TransactionConfig
): Promise<TResult> {
return await this.entityCompanionProvider
.getQueryContextProviderForDatabaseAdaptorFlavor(databaseAdaptorFlavor)
.getQueryContext()
.runInTransactionIfNotInTransactionAsync(transactionScope);
.runInTransactionIfNotInTransactionAsync(transactionScope, transactionConfig);
}
}
24 changes: 23 additions & 1 deletion packages/entity/src/__tests__/EntityQueryContext-test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<any> => {};
const transactionConfig = { isolationLevel: TransactionIsolationLevel.SERIALIZABLE };

await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
'postgres',
transactionScopeFn,
transactionConfig
);

expect(queryContextProviderSpy).toHaveBeenCalledWith(transactionScopeFn, transactionConfig);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,8 @@ describe(EntityDataManager, () => {
return await entityDataManager.loadManyByFieldEqualingAsync(queryContext, 'customIdField', [
'1',
]);
}
},
{}
);

expect(entityDatas.get('1')).toHaveLength(1);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { TransactionConfig } from '../../EntityQueryContext';
import EntityQueryContextProvider from '../../EntityQueryContextProvider';

export class StubQueryContextProvider extends EntityQueryContextProvider {
protected getQueryInterface(): any {
return {};
}

protected createTransactionRunner<T>(): (
transactionScope: (queryInterface: any) => Promise<T>
) => Promise<T> {
protected createTransactionRunner<T>(
_transactionConfig?: TransactionConfig
): (transactionScope: (queryInterface: any) => Promise<T>) => Promise<T> {
return (transactionScope) => Promise.resolve(transactionScope({}));
}

Expand Down