From cb66e234003b436ac979593663c26ebe13e46a3a Mon Sep 17 00:00:00 2001 From: Will Schurman Date: Thu, 24 Sep 2026 13:34:17 -0700 Subject: [PATCH] feat: add forShare and skipLocked selection modifiers to knex loader --- .../src/StubPostgresDatabaseAdapter.ts | 9 +- ...uthorizationResultBasedKnexEntityLoader.ts | 14 +- .../src/BasePostgresEntityDatabaseAdapter.ts | 16 +- .../src/BaseSQLQueryBuilder.ts | 22 +++ .../src/PostgresEntityDatabaseAdapter.ts | 19 ++- .../PostgresEntityIntegration-test.ts | 141 ++++++++++++++++- .../BasePostgresEntityDatabaseAdapter-test.ts | 28 +++- .../fixtures/StubPostgresDatabaseAdapter.ts | 9 +- .../src/internal/EntityKnexDataManager.ts | 24 +-- .../__tests__/EntityKnexDataManager-test.ts | 143 +++++++++++++++++- 10 files changed, 394 insertions(+), 31 deletions(-) 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 f38b92c05..1eac44a32 100644 --- a/packages/entity-database-adapter-knex-testing-utils/src/StubPostgresDatabaseAdapter.ts +++ b/packages/entity-database-adapter-knex-testing-utils/src/StubPostgresDatabaseAdapter.ts @@ -212,7 +212,14 @@ export class StubPostgresDatabaseAdapter< tableName, tableFieldSingleValueEqualityOperands, tableFieldMultiValueEqualityOperands, - { orderBy: undefined, offset: undefined, limit: undefined, forUpdate: undefined }, + { + orderBy: undefined, + offset: undefined, + limit: undefined, + forUpdate: undefined, + forShare: undefined, + skipLocked: undefined, + }, ); return results.length; } diff --git a/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts b/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts index a38cf4a3e..d9d2b3bda 100644 --- a/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts +++ b/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts @@ -88,9 +88,21 @@ export interface EntityLoaderQuerySelectionModifiers< /** * Lock the selected rows for update using `SELECT ... FOR UPDATE`. The lock is held until the - * end of the transaction, so the query context must be transactional. + * end of the transaction, so the query context must be transactional. Mutually exclusive with `forShare`. */ forUpdate?: boolean; + + /** + * Lock the selected rows in share mode using `SELECT ... FOR SHARE`. The lock is held until the + * end of the transaction, so the query context must be transactional. Mutually exclusive with `forUpdate`. + */ + forShare?: boolean; + + /** + * Skip rows that are already locked by another transaction using `SKIP LOCKED` instead of waiting for them. + * Requires `forUpdate` or `forShare`. + */ + skipLocked?: boolean; } /** diff --git a/packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts b/packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts index c8a2f469d..386265a56 100644 --- a/packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts +++ b/packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts @@ -136,9 +136,19 @@ export interface PostgresQuerySelectionModifiers> = @@ -158,6 +168,8 @@ export interface TableQuerySelectionModifiers[]; forUpdate?: boolean; + forShare?: boolean; + skipLocked?: boolean; }, ) {} @@ -45,12 +47,32 @@ export abstract class BaseSQLQueryBuilder< /** * Lock the selected rows for update using `SELECT ... FOR UPDATE`. The lock is held until the * end of the transaction, so the query must be executed in a transactional query context. + * Mutually exclusive with `forShare`. */ forUpdate(): this { this.modifiers.forUpdate = true; return this; } + /** + * Lock the selected rows in share mode using `SELECT ... FOR SHARE`. The lock is held until the + * end of the transaction, so the query must be executed in a transactional query context. + * Mutually exclusive with `forUpdate`. + */ + forShare(): this { + this.modifiers.forShare = true; + return this; + } + + /** + * Skip rows that are already locked by another transaction using `SKIP LOCKED` instead of waiting for them. + * Requires `forUpdate()` or `forShare()`. + */ + skipLocked(): this { + this.modifiers.skipLocked = true; + return this; + } + /** * Order by a field. Can be called multiple times to add multiple order bys. */ diff --git a/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts b/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts index 5843b3a55..1716a6044 100644 --- a/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts +++ b/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts @@ -116,7 +116,14 @@ export class PostgresEntityDatabaseAdapter< tableValue: tableTuple[index], })), [], - { limit: 1, orderBy: undefined, offset: undefined, forUpdate: undefined }, + { + limit: 1, + orderBy: undefined, + offset: undefined, + forUpdate: undefined, + forShare: undefined, + skipLocked: undefined, + }, ); return results[0] ?? null; } @@ -125,7 +132,7 @@ export class PostgresEntityDatabaseAdapter< query: Knex.QueryBuilder, querySelectionModifiers: TableQuerySelectionModifiers, ): Knex.QueryBuilder { - const { orderBy, offset, limit, forUpdate } = querySelectionModifiers; + const { orderBy, offset, limit, forUpdate, forShare, skipLocked } = querySelectionModifiers; let ret = query; @@ -165,6 +172,14 @@ export class PostgresEntityDatabaseAdapter< ret = ret.forUpdate(); } + if (forShare) { + ret = ret.forShare(); + } + + if (skipLocked) { + ret = ret.skipLocked(); + } + return ret; } 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 a6661118f..f384dd8fa 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 @@ -211,13 +211,16 @@ describe('postgres entity integration', () => { ); }); - describe('forUpdate', () => { + describe('row locking modifiers (forUpdate, forShare, skipLocked)', () => { // Attempts to lock the row from a separate connection without waiting. Postgres raises - // lock_not_available (55P03) when another transaction already holds a FOR UPDATE lock on the row. - const tryLockRowFromOtherConnectionAsync = async (id: string): Promise => { + // lock_not_available (55P03) when another transaction already holds a conflicting lock on the row. + const tryLockRowFromOtherConnectionAsync = async ( + id: string, + lockMode: 'FOR UPDATE' | 'FOR SHARE' = 'FOR UPDATE', + ): Promise => { try { await knexInstance.raw( - 'SELECT * FROM postgres_test_entities WHERE id = ? FOR UPDATE NOWAIT', + `SELECT * FROM postgres_test_entities WHERE id = ? ${lockMode} NOWAIT`, [id], ); return null; @@ -353,21 +356,145 @@ describe('postgres entity integration', () => { expect(reloaded.getField('name')).toBe('counter,a,b'); }); - it('throws when forUpdate is used outside of a transaction', async () => { + it('forShare allows concurrent share locks but blocks update locks', async () => { + const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + const entity = await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'shared') + .createAsync(), + ); + + await vc1.runInTransactionForDatabaseAdapterFlavorAsync('postgres', async (queryContext) => { + const results = await PostgresTestEntity.knexLoader( + vc1, + queryContext, + ).loadManyByFieldEqualityConjunctionAsync([{ fieldName: 'name', fieldValue: 'shared' }], { + forShare: true, + }); + expect(results).toHaveLength(1); + + // another share lock is compatible, an update lock is not + expect(await tryLockRowFromOtherConnectionAsync(entity.getID(), 'FOR SHARE')).toBeNull(); + expect(await tryLockRowFromOtherConnectionAsync(entity.getID(), 'FOR UPDATE')).toBe( + '55P03', + ); + + // via the fluent builder method + const builderResults = await PostgresTestEntity.knexLoader(vc1, queryContext) + .loadManyBySQL(sql`name = ${'shared'}`) + .forShare() + .executeAsync(); + expect(builderResults).toHaveLength(1); + }); + + expect(await tryLockRowFromOtherConnectionAsync(entity.getID(), 'FOR UPDATE')).toBeNull(); + }); + + it('skipLocked skips rows locked by another transaction', async () => { + const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + const entityA = await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'queue') + .createAsync(), + ); + const entityB = await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'queue') + .createAsync(), + ); + + await vc1.runInTransactionForDatabaseAdapterFlavorAsync( + 'postgres', + async (outerQueryContext) => { + // lock entityA in the outer transaction + const locked = await PostgresTestEntity.knexLoader(vc1, outerQueryContext) + .loadManyBySQL(sql`id = ${entityA.getID()}`) + .forUpdate() + .executeAsync(); + expect(locked.map((e) => e.getID())).toEqual([entityA.getID()]); + + // a second transaction on another connection sees only the unlocked row + const vc2 = new ViewerContext( + createKnexIntegrationTestEntityCompanionProvider(knexInstance), + ); + await vc2.runInTransactionForDatabaseAdapterFlavorAsync( + 'postgres', + async (innerQueryContext) => { + const skipLockedResults = await PostgresTestEntity.knexLoader( + vc2, + innerQueryContext, + ).loadManyByFieldEqualityConjunctionAsync( + [{ fieldName: 'name', fieldValue: 'queue' }], + { forUpdate: true, skipLocked: true }, + ); + expect(skipLockedResults.map((e) => e.getID())).toEqual([entityB.getID()]); + + const builderResults = await PostgresTestEntity.knexLoader(vc2, innerQueryContext) + .loadManyBySQL(sql`name = ${'queue'}`) + .forShare() + .skipLocked() + .executeAsync(); + expect(builderResults.map((e) => e.getID())).toEqual([entityB.getID()]); + }, + ); + }, + ); + + // both rows are visible once the outer transaction commits + await vc1.runInTransactionForDatabaseAdapterFlavorAsync('postgres', async (queryContext) => { + const results = await PostgresTestEntity.knexLoader( + vc1, + queryContext, + ).loadManyByFieldEqualityConjunctionAsync([{ fieldName: 'name', fieldValue: 'queue' }], { + forUpdate: true, + skipLocked: true, + }); + expect(results).toHaveLength(2); + }); + }); + + it('throws when forUpdate or forShare is used outside of a transaction', async () => { const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); await expect( PostgresTestEntity.knexLoader(vc1).loadManyByFieldEqualityConjunctionAsync([], { forUpdate: true, }), - ).rejects.toThrow('forUpdate requires a transactional query context'); + ).rejects.toThrow('require a transactional query context'); + + await expect( + PostgresTestEntity.knexLoader(vc1).loadManyByFieldEqualityConjunctionAsync([], { + forShare: true, + }), + ).rejects.toThrow('require a transactional query context'); await expect( PostgresTestEntity.knexLoader(vc1) .loadManyBySQL(sql`TRUE`) .forUpdate() .executeAsync(), - ).rejects.toThrow('forUpdate requires a transactional query context'); + ).rejects.toThrow('require a transactional query context'); + }); + + it('throws for invalid row locking modifier combinations', async () => { + const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + await vc1.runInTransactionForDatabaseAdapterFlavorAsync('postgres', async (queryContext) => { + await expect( + PostgresTestEntity.knexLoader(vc1, queryContext) + .loadManyBySQL(sql`TRUE`) + .forUpdate() + .forShare() + .executeAsync(), + ).rejects.toThrow('forUpdate and forShare are mutually exclusive'); + + await expect( + PostgresTestEntity.knexLoader(vc1, queryContext) + .loadManyBySQL(sql`TRUE`) + .skipLocked() + .executeAsync(), + ).rejects.toThrow('skipLocked requires forUpdate or forShare'); + }); }); }); 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 479aed883..312fea7c2 100644 --- a/packages/entity-database-adapter-knex/src/__tests__/BasePostgresEntityDatabaseAdapter-test.ts +++ b/packages/entity-database-adapter-knex/src/__tests__/BasePostgresEntityDatabaseAdapter-test.ts @@ -164,19 +164,32 @@ describe(BasePostgresEntityDatabaseAdapter, () => { expect(results).toEqual([{ stringField: 'hello' }]); }); - it('converts query selection modifiers including forUpdate', async () => { + it('converts query selection modifiers including row locking modifiers', async () => { const queryContext = instance(mock(EntityQueryContext)); const adapter = new TestEntityDatabaseAdapter({}); await adapter.fetchManyByFieldEqualityConjunctionAsync(queryContext, [], { limit: 2, offset: 1, forUpdate: true, + skipLocked: true, }); expect(adapter.lastEqualityConditionQuerySelectionModifiers).toEqual({ orderBy: undefined, limit: 2, offset: 1, forUpdate: true, + forShare: undefined, + skipLocked: true, + }); + + await adapter.fetchManyByFieldEqualityConjunctionAsync(queryContext, [], { forShare: true }); + expect(adapter.lastEqualityConditionQuerySelectionModifiers).toEqual({ + orderBy: undefined, + limit: undefined, + offset: undefined, + forUpdate: undefined, + forShare: true, + skipLocked: undefined, }); await adapter.fetchManyByFieldEqualityConjunctionAsync(queryContext, [], {}); @@ -185,20 +198,27 @@ describe(BasePostgresEntityDatabaseAdapter, () => { limit: undefined, offset: undefined, forUpdate: undefined, + forShare: undefined, + skipLocked: undefined, }); }); }); describe('fetchManyBySQLFragmentAsync', () => { - it('converts query selection modifiers including forUpdate', async () => { + it('converts query selection modifiers including row locking modifiers', async () => { const queryContext = instance(mock(EntityQueryContext)); const adapter = new TestEntityDatabaseAdapter({}); - await adapter.fetchManyBySQLFragmentAsync(queryContext, sql`TRUE`, { forUpdate: true }); + await adapter.fetchManyBySQLFragmentAsync(queryContext, sql`TRUE`, { + forShare: true, + skipLocked: true, + }); expect(adapter.lastSQLFragmentQuerySelectionModifiers).toEqual({ orderBy: undefined, limit: undefined, offset: undefined, - forUpdate: true, + forUpdate: undefined, + forShare: true, + skipLocked: true, }); }); }); 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 943d8d4b9..6ca46ff4d 100644 --- a/packages/entity-database-adapter-knex/src/__tests__/fixtures/StubPostgresDatabaseAdapter.ts +++ b/packages/entity-database-adapter-knex/src/__tests__/fixtures/StubPostgresDatabaseAdapter.ts @@ -213,7 +213,14 @@ export class StubPostgresDatabaseAdapter< tableName, tableFieldSingleValueEqualityOperands, tableFieldMultiValueEqualityOperands, - { orderBy: undefined, offset: undefined, limit: undefined, forUpdate: undefined }, + { + orderBy: undefined, + offset: undefined, + limit: undefined, + forUpdate: undefined, + forShare: undefined, + skipLocked: undefined, + }, ); return results.length; } diff --git a/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts b/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts index 19f4a7259..96979830a 100644 --- a/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts +++ b/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts @@ -177,7 +177,7 @@ export class EntityKnexDataManager< querySelectionModifiers: PostgresQuerySelectionModifiers, ): Promise[]> { EntityKnexDataManager.validateOrderByClauses(querySelectionModifiers.orderBy); - EntityKnexDataManager.validateForUpdate(queryContext, querySelectionModifiers.forUpdate); + EntityKnexDataManager.validateRowLockingModifiers(queryContext, querySelectionModifiers); return await timeAndLogLoadEventAsync( this.metricsAdapter, @@ -216,7 +216,7 @@ export class EntityKnexDataManager< querySelectionModifiers: PostgresQuerySelectionModifiers, ): Promise[]> { EntityKnexDataManager.validateOrderByClauses(querySelectionModifiers.orderBy); - EntityKnexDataManager.validateForUpdate(queryContext, querySelectionModifiers.forUpdate); + EntityKnexDataManager.validateRowLockingModifiers(queryContext, querySelectionModifiers); return await timeAndLogLoadEventAsync( this.metricsAdapter, @@ -534,20 +534,26 @@ export class EntityKnexDataManager< } /** - * `SELECT ... FOR UPDATE` row locks are released at the end of the transaction. Outside of a - * transaction the lock is released as soon as the statement completes, which makes it useless, - * so require a transactional query context. + * Validates row locking modifiers (forUpdate, forShare, skipLocked). + * + * `SELECT ... FOR UPDATE` and `SELECT ... FOR SHARE` row locks are released at the end of the + * transaction. Outside of a transaction the lock is released as soon as the statement completes, + * which makes it useless, so require a transactional query context. FOR UPDATE and FOR SHARE + * cannot be combined in a single statement, and SKIP LOCKED is only valid with a row lock. */ - private static validateForUpdate( + private static validateRowLockingModifiers>( queryContext: EntityQueryContext, - forUpdate: boolean | undefined, + querySelectionModifiers: PostgresQuerySelectionModifiers, ): void { - if (!forUpdate) { + const { forUpdate, forShare, skipLocked } = querySelectionModifiers; + if (!forUpdate && !forShare) { + assert(!skipLocked, 'skipLocked requires forUpdate or forShare.'); return; } + assert(!(forUpdate && forShare), 'forUpdate and forShare are mutually exclusive.'); assert( queryContext.isInTransaction(), - 'forUpdate requires a transactional query context since row locks are released at the end of the transaction.', + 'forUpdate and forShare require a transactional query context since row locks are released at the end of the transaction.', ); } diff --git a/packages/entity-database-adapter-knex/src/internal/__tests__/EntityKnexDataManager-test.ts b/packages/entity-database-adapter-knex/src/internal/__tests__/EntityKnexDataManager-test.ts index 466111e0c..bc5836273 100644 --- a/packages/entity-database-adapter-knex/src/internal/__tests__/EntityKnexDataManager-test.ts +++ b/packages/entity-database-adapter-knex/src/internal/__tests__/EntityKnexDataManager-test.ts @@ -194,7 +194,7 @@ describe(EntityKnexDataManager, () => { }); }); - describe('forUpdate', () => { + describe('row locking modifiers', () => { const fieldObject = { customIdField: '1', testIndexedField: 'unique1', @@ -220,7 +220,7 @@ describe(EntityKnexDataManager, () => { entityDataManager.loadManyByFieldEqualityConjunctionAsync(queryContext, [], { forUpdate: true, }), - ).rejects.toThrow('forUpdate requires a transactional query context'); + ).rejects.toThrow('require a transactional query context'); verify( databaseAdapterMock.fetchManyByFieldEqualityConjunctionAsync( anything(), @@ -246,7 +246,7 @@ describe(EntityKnexDataManager, () => { entityDataManager.loadManyBySQLFragmentAsync(queryContext, sql`TRUE`, { forUpdate: true, }), - ).rejects.toThrow('forUpdate requires a transactional query context'); + ).rejects.toThrow('require a transactional query context'); verify( databaseAdapterMock.fetchManyBySQLFragmentAsync(anything(), anything(), anything()), ).never(); @@ -306,7 +306,140 @@ describe(EntityKnexDataManager, () => { }); }); - it('does not require a transaction when forUpdate is not set', async () => { + it('throws when forShare is used outside of a transaction', async () => { + const queryContext = new StubQueryContextProvider().getQueryContext(); + const databaseAdapterMock = mock>( + PostgresEntityDatabaseAdapter, + ); + const entityDataManager = new EntityKnexDataManager( + testEntityConfiguration, + instance(databaseAdapterMock), + new NoOpEntityMetricsAdapter(), + TestEntity.name, + ); + + await expect( + entityDataManager.loadManyByFieldEqualityConjunctionAsync(queryContext, [], { + forShare: true, + }), + ).rejects.toThrow('require a transactional query context'); + await expect( + entityDataManager.loadManyBySQLFragmentAsync(queryContext, sql`TRUE`, { forShare: true }), + ).rejects.toThrow('require a transactional query context'); + }); + + it('throws when forUpdate and forShare are both set', async () => { + const databaseAdapterMock = mock>( + PostgresEntityDatabaseAdapter, + ); + const entityDataManager = new EntityKnexDataManager( + testEntityConfiguration, + instance(databaseAdapterMock), + new NoOpEntityMetricsAdapter(), + TestEntity.name, + ); + + await new StubQueryContextProvider().runInTransactionAsync(async (queryContext) => { + await expect( + entityDataManager.loadManyByFieldEqualityConjunctionAsync(queryContext, [], { + forUpdate: true, + forShare: true, + }), + ).rejects.toThrow('forUpdate and forShare are mutually exclusive'); + await expect( + entityDataManager.loadManyBySQLFragmentAsync(queryContext, sql`TRUE`, { + forUpdate: true, + forShare: true, + }), + ).rejects.toThrow('forUpdate and forShare are mutually exclusive'); + }); + verify( + databaseAdapterMock.fetchManyByFieldEqualityConjunctionAsync( + anything(), + anything(), + anything(), + ), + ).never(); + verify( + databaseAdapterMock.fetchManyBySQLFragmentAsync(anything(), anything(), anything()), + ).never(); + }); + + it('throws when skipLocked is set without forUpdate or forShare', async () => { + const databaseAdapterMock = mock>( + PostgresEntityDatabaseAdapter, + ); + const entityDataManager = new EntityKnexDataManager( + testEntityConfiguration, + instance(databaseAdapterMock), + new NoOpEntityMetricsAdapter(), + TestEntity.name, + ); + + await new StubQueryContextProvider().runInTransactionAsync(async (queryContext) => { + await expect( + entityDataManager.loadManyByFieldEqualityConjunctionAsync(queryContext, [], { + skipLocked: true, + }), + ).rejects.toThrow('skipLocked requires forUpdate or forShare'); + await expect( + entityDataManager.loadManyBySQLFragmentAsync(queryContext, sql`TRUE`, { + skipLocked: true, + }), + ).rejects.toThrow('skipLocked requires forUpdate or forShare'); + }); + }); + + it('passes forShare and skipLocked through to the database adapter inside of a transaction', async () => { + const databaseAdapterMock = mock>( + PostgresEntityDatabaseAdapter, + ); + when( + databaseAdapterMock.fetchManyByFieldEqualityConjunctionAsync( + anything(), + anything(), + anything(), + ), + ).thenResolve([fieldObject]); + when( + databaseAdapterMock.fetchManyBySQLFragmentAsync(anything(), anything(), anything()), + ).thenResolve([fieldObject]); + + const entityDataManager = new EntityKnexDataManager( + testEntityConfiguration, + instance(databaseAdapterMock), + new NoOpEntityMetricsAdapter(), + TestEntity.name, + ); + + await new StubQueryContextProvider().runInTransactionAsync(async (queryContext) => { + await entityDataManager.loadManyByFieldEqualityConjunctionAsync(queryContext, [], { + forShare: true, + skipLocked: true, + }); + await entityDataManager.loadManyBySQLFragmentAsync(queryContext, sql`TRUE`, { + forUpdate: true, + skipLocked: true, + }); + + verify( + databaseAdapterMock.fetchManyByFieldEqualityConjunctionAsync( + queryContext, + anything(), + deepEqual({ forShare: true, skipLocked: true }), + ), + ).once(); + verify( + databaseAdapterMock.fetchManyBySQLFragmentAsync( + queryContext, + anything(), + deepEqual({ forUpdate: true, skipLocked: true }), + ), + ).once(); + }); + }); + + it('does not require a transaction when no row locking modifier is set', async () => { const queryContext = new StubQueryContextProvider().getQueryContext(); const databaseAdapterMock = mock>( PostgresEntityDatabaseAdapter, @@ -328,7 +461,7 @@ describe(EntityKnexDataManager, () => { const results = await entityDataManager.loadManyByFieldEqualityConjunctionAsync( queryContext, [], - { forUpdate: false }, + { forUpdate: false, forShare: false, skipLocked: false }, ); expect(results).toHaveLength(1); });