Skip to content

Commit d34ca5f

Browse files
committed
fix(testing): harden table routing — join-table queues, direct-await from, mutation isolation
- track the chain's tables as a list (from + joins) so rows queued for a join-only table route correctly; from-table queue checked first - make the from/join builder a lazy thenable so awaiting a select with no where clause resolves queued rows (dequeue at await, never double-consumed) - update/delete/set clear the routing context so a mutation's where() can never consume rows queued for a select - document the left-to-right chain-construction assumption; contract tests for all three behaviors
1 parent b291afe commit d34ca5f

2 files changed

Lines changed: 77 additions & 24 deletions

File tree

packages/testing/src/mocks/database.mock.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,34 @@ describe('database mock', () => {
6767
).resolves.toEqual([{ id: 'w-2' }])
6868
})
6969

70+
it('resolves queued rows when a from-chain is awaited directly (no where)', async () => {
71+
queueTableRows(workflowTable, [{ id: 'direct' }])
72+
await expect(db.select().from(workflowTable)).resolves.toEqual([{ id: 'direct' }])
73+
await expect(db.select().from(workflowTable)).resolves.toEqual([])
74+
})
75+
76+
it('routes rows queued for a table referenced only by a join', async () => {
77+
queueTableRows(memberTable, [{ id: 'joined' }])
78+
await expect(
79+
db.select().from(workflowTable).leftJoin(memberTable, {}).where({})
80+
).resolves.toEqual([{ id: 'joined' }])
81+
})
82+
83+
it('prefers the from-table queue over a join-table queue', async () => {
84+
queueTableRows(workflowTable, [{ id: 'from-row' }])
85+
queueTableRows(memberTable, [{ id: 'join-row' }])
86+
await expect(
87+
db.select().from(workflowTable).innerJoin(memberTable, {}).where({})
88+
).resolves.toEqual([{ id: 'from-row' }])
89+
})
90+
91+
it('never lets mutation chains consume select queues', async () => {
92+
queueTableRows(workflowTable, [{ id: 'kept' }])
93+
await expect(db.update(workflowTable).set({}).where({})).resolves.toEqual([])
94+
await expect(db.delete(workflowTable).where({})).resolves.toEqual([])
95+
await expect(db.select().from(workflowTable).where({})).resolves.toEqual([{ id: 'kept' }])
96+
})
97+
7098
it('routes selectDistinctOn chains through the same table queues', async () => {
7199
queueTableRows(memberTable, [{ id: 'm-1' }])
72100
await expect(db.selectDistinctOn(['id']).from(memberTable).where({})).resolves.toEqual([

packages/testing/src/mocks/database.mock.ts

Lines changed: 49 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -64,18 +64,24 @@ export function createMockSqlOperators() {
6464
* Table-routed result queues.
6565
*
6666
* `queueTableRows(schemaMock.member, [rowA, rowB])` enqueues one result set for
67-
* the next select chain whose `.from()` (or subsequent join) references that
68-
* table. Each chain consumes at most one queued set (FIFO per table); chains
67+
* the next select chain whose `.from()` (or a subsequent `innerJoin`/
68+
* `leftJoin`) references that table. Each chain consumes at most one queued
69+
* set (FIFO per table, `.from()` table checked before join tables); chains
6970
* against tables with no queued sets resolve the chain-fn defaults (empty
7071
* array). Queues are cleared by `resetDbChainMock()`.
7172
*
7273
* The queue is keyed by table object identity, so pass the same schema-mock
73-
* table object the code under test passes to `.from()`.
74+
* table object the code under test passes to `.from()` / the join.
75+
*
76+
* Routing assumes each select chain is built left-to-right before the next
77+
* chain starts — the norm for code under test (`Promise.all` over fully-built
78+
* chains is fine; interleaving *construction* of two chains is not). Mutation
79+
* chains (`update`/`delete`/`insert`) never consume select queues.
7480
*/
7581
const tableRowQueues = new Map<unknown, unknown[][]>()
7682

77-
/** The `.from()` table of the select chain currently being built. */
78-
let activeTable: unknown
83+
/** Tables of the select chain currently being built: `.from()` first, then joins. */
84+
let activeTables: unknown[] = []
7985

8086
/** Rows dequeued for the current chain, shared by every downstream terminal. */
8187
let activeRows: unknown[] | null = null
@@ -89,10 +95,13 @@ export function queueTableRows(table: unknown, rows: unknown[]): void {
8995
else tableRowQueues.set(table, [rows])
9096
}
9197

92-
function dequeueRows(table: unknown): unknown[] | null {
93-
const queue = tableRowQueues.get(table)
94-
if (!queue || queue.length === 0) return null
95-
return queue.shift() ?? null
98+
/** Dequeues the first queued set among the chain's tables (from before joins). */
99+
function dequeueActiveRows(): unknown[] | null {
100+
for (const table of activeTables) {
101+
const queue = tableRowQueues.get(table)
102+
if (queue && queue.length > 0) return queue.shift() ?? null
103+
}
104+
return null
96105
}
97106

98107
/**
@@ -178,7 +187,7 @@ const onConflictDoNothing = vi.fn(() => ({ returning }) as unknown as Promise<vo
178187
const whereBuilder = () => {
179188
// Dequeue table-routed rows when the where clause materializes; every
180189
// downstream terminal (limit/orderBy/...) then resolves the same rows.
181-
activeRows = dequeueRows(activeTable)
190+
activeRows = dequeueActiveRows()
182191
// Some call sites await the where directly (no limit/orderBy), so the
183192
// builder is itself a thenable.
184193
const thenable: any = chainRows()
@@ -191,15 +200,24 @@ const whereBuilder = () => {
191200
}
192201
const where = vi.fn(whereBuilder)
193202

194-
const joinBuilder = (): { where: typeof where; innerJoin: any; leftJoin: any } => ({
203+
// The from/join builder is itself a thenable so `await db.select().from(t)`
204+
// (no where clause) also resolves table-routed rows. Dequeue happens lazily at
205+
// await time, so a chain that continues into `.where()` never double-consumes.
206+
const joinBuilder = (): { where: typeof where; innerJoin: any; leftJoin: any; then: any } => ({
195207
where,
196208
innerJoin,
197209
leftJoin,
210+
then: (onFulfilled?: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
211+
Promise.resolve((dequeueActiveRows() ?? []) as unknown[]).then(onFulfilled, onRejected),
198212
})
199-
const innerJoin: ReturnType<typeof vi.fn> = vi.fn(joinBuilder)
200-
const leftJoin: ReturnType<typeof vi.fn> = vi.fn(joinBuilder)
213+
const joinStep = (table?: unknown) => {
214+
activeTables.push(table)
215+
return joinBuilder()
216+
}
217+
const innerJoin: ReturnType<typeof vi.fn> = vi.fn(joinStep)
218+
const leftJoin: ReturnType<typeof vi.fn> = vi.fn(joinStep)
201219
const from = vi.fn((table?: unknown) => {
202-
activeTable = table
220+
activeTables = [table]
203221
activeRows = null
204222
return joinBuilder()
205223
})
@@ -209,9 +227,16 @@ const selectDistinct = vi.fn(() => ({ from }))
209227
const selectDistinctOn = vi.fn(() => ({ from }))
210228
const values = vi.fn(() => ({ returning, onConflictDoUpdate, onConflictDoNothing }))
211229
const insert = vi.fn(() => ({ values }))
212-
const set = vi.fn(() => ({ where }))
213-
const update = vi.fn(() => ({ set }))
214-
const del = vi.fn(() => ({ where }))
230+
// Mutation chains clear the routing context so their `where()` never consumes
231+
// rows queued for a select.
232+
const mutationStep = <T>(next: T): T => {
233+
activeTables = []
234+
activeRows = null
235+
return next
236+
}
237+
const set = vi.fn(() => mutationStep({ where }))
238+
const update = vi.fn(() => mutationStep({ set }))
239+
const del = vi.fn(() => mutationStep({ where }))
215240
const query = vi.fn(() => Promise.resolve([] as unknown[]))
216241
const transaction: ReturnType<typeof vi.fn> = vi.fn(
217242
async (cb: (tx: any) => unknown): Promise<unknown> => cb(dbChainMock.db)
@@ -255,26 +280,26 @@ export const dbChainMockFns = {
255280
*/
256281
export function resetDbChainMock(): void {
257282
tableRowQueues.clear()
258-
activeTable = undefined
283+
activeTables = []
259284
activeRows = null
260285
select.mockImplementation(() => ({ from }))
261286
selectDistinct.mockImplementation(() => ({ from }))
262287
selectDistinctOn.mockImplementation(() => ({ from }))
263288
from.mockImplementation((table?: unknown) => {
264-
activeTable = table
289+
activeTables = [table]
265290
activeRows = null
266291
return joinBuilder()
267292
})
268-
innerJoin.mockImplementation(joinBuilder)
269-
leftJoin.mockImplementation(joinBuilder)
293+
innerJoin.mockImplementation(joinStep)
294+
leftJoin.mockImplementation(joinStep)
270295
where.mockImplementation(whereBuilder)
271296
insert.mockImplementation(() => ({ values }))
272297
values.mockImplementation(() => ({ returning, onConflictDoUpdate, onConflictDoNothing }))
273298
onConflictDoUpdate.mockImplementation(() => ({ returning }) as unknown as Promise<void>)
274299
onConflictDoNothing.mockImplementation(() => ({ returning }) as unknown as Promise<void>)
275-
update.mockImplementation(() => ({ set }))
276-
set.mockImplementation(() => ({ where }))
277-
del.mockImplementation(() => ({ where }))
300+
update.mockImplementation(() => mutationStep({ set }))
301+
set.mockImplementation(() => mutationStep({ where }))
302+
del.mockImplementation(() => mutationStep({ where }))
278303
limit.mockImplementation(limitBuilder)
279304
offset.mockImplementation(chainRows)
280305
orderBy.mockImplementation(terminalBuilder)

0 commit comments

Comments
 (0)