Skip to content

Commit 39463dc

Browse files
committed
refactor(testing): move all chain routing state into per-chain closures
- shared dbChainMockFns entries become pure spy/override ports: their default implementation returns a sentinel that chain-local builders replace, while any mock* override on the spy wins verbatim - each select().from() captures its own immutable table list; where(), joins, terminals, and direct awaits all resolve through that closure, so partially-built chains for different tables interleave without cross-talk - no module-level routing state remains
1 parent 858ae53 commit 39463dc

1 file changed

Lines changed: 121 additions & 140 deletions

File tree

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

Lines changed: 121 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -68,24 +68,14 @@ export function createMockSqlOperators() {
6868
* `leftJoin`) references that table. Each chain consumes at most one queued
6969
* set (FIFO per table, `.from()` table checked before join tables); chains
7070
* against tables with no queued sets resolve the chain-fn defaults (empty
71-
* array). Queues are cleared by `resetDbChainMock()`.
71+
* array). Mutation chains (`update`/`delete`/`insert`) never consume select
72+
* queues. Queues are cleared by `resetDbChainMock()`.
7273
*
7374
* The queue is keyed by table object identity, so pass the same schema-mock
7475
* 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.
8076
*/
8177
const tableRowQueues = new Map<unknown, unknown[][]>()
8278

83-
/** Tables of the select chain currently being built: `.from()` first, then joins. */
84-
let activeTables: unknown[] = []
85-
86-
/** Rows dequeued for the current chain, shared by every downstream terminal. */
87-
let activeRows: unknown[] | null = null
88-
8979
/**
9080
* Enqueues one result set for the next select chain reading `table`.
9181
*/
@@ -107,9 +97,9 @@ function dequeueChainRows(tables: unknown[]): unknown[] | null {
10797
/**
10898
* Pre-wired chain of vi.fn()s for drizzle-style DB queries.
10999
*
110-
* Each builder step is a stable, module-level `vi.fn()` — safe to reference
111-
* inside hoisted `vi.mock()` factories (same pattern as `authMockFns`). Chains
112-
* are wired at module load time:
100+
* Each chain step is recorded on a stable, module-level `vi.fn()` spy
101+
* (`dbChainMockFns.*`) — safe to reference inside hoisted `vi.mock()`
102+
* factories (same pattern as `authMockFns`):
113103
*
114104
* - `select().from().where()` → returns a builder with `.limit` / `.orderBy` /
115105
* `.returning` / `.groupBy` / `.for` terminals
@@ -118,9 +108,16 @@ function dequeueChainRows(tables: unknown[]): unknown[] | null {
118108
*
119109
* Results resolve, in priority order:
120110
* 1. a per-test override (`dbChainMockFns.limit.mockResolvedValueOnce([...])`)
121-
* 2. rows queued for the chain's `.from()` table via `queueTableRows`
111+
* 2. rows queued for one of the chain's tables via `queueTableRows`
122112
* 3. the default empty array
123113
*
114+
* Routing state lives in per-chain closures: each `select().from(t)` captures
115+
* its own table list, so partially-built chains for different tables can be
116+
* interleaved or awaited in any order without cross-talk. The shared spies
117+
* carry only call history and per-test overrides — a spy's default
118+
* implementation returns a sentinel that the chain replaces with the
119+
* chain-local builder, while any `mock*` override on the spy wins verbatim.
120+
*
124121
* `for` mirrors drizzle's `.for('update')` — it returns a Promise with
125122
* `.limit` / `.orderBy` / `.returning` / `.groupBy` attached, so both
126123
* `await .where().for('update')` (terminal) and
@@ -132,8 +129,7 @@ function dequeueChainRows(tables: unknown[]): unknown[] | null {
132129
*
133130
* @example
134131
* ```ts
135-
* import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
136-
* import { schemaMock } from '@sim/testing'
132+
* import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing'
137133
*
138134
* beforeEach(() => {
139135
* vi.clearAllMocks()
@@ -147,104 +143,95 @@ function dequeueChainRows(tables: unknown[]): unknown[] | null {
147143
* })
148144
* ```
149145
*/
150-
const chainRows = () => Promise.resolve((activeRows ?? []) as unknown[])
146+
const CHAIN_DEFAULT = Symbol('db-chain-default')
151147

152-
const offset = vi.fn(chainRows)
153-
// `.limit()` returns a builder that is awaitable and also exposes `.offset()`
154-
// for keyset/OFFSET paging (`.limit(n).offset(m)`).
155-
const limitBuilder = () => {
156-
const thenable: any = chainRows()
157-
thenable.offset = offset
158-
return thenable
159-
}
160-
const limit = vi.fn(limitBuilder)
161-
const returning = vi.fn(() => Promise.resolve([] as unknown[]))
162-
const execute = vi.fn(() => Promise.resolve([] as unknown[]))
148+
type ChainSpy = ReturnType<typeof vi.fn<(...args: any[]) => any>>
163149

164-
const terminalBuilder = () => {
165-
const thenable: any = chainRows()
166-
thenable.limit = limit
167-
thenable.orderBy = orderBy
168-
thenable.returning = returning
169-
thenable.groupBy = groupBy
170-
thenable.for = forClause
171-
return thenable
172-
}
150+
const chainSpy = (): ChainSpy => vi.fn((..._args: any[]) => CHAIN_DEFAULT as any)
173151

174-
const orderBy = vi.fn(terminalBuilder)
175-
const having = vi.fn(terminalBuilder)
176-
const groupBy = vi.fn(() => {
177-
const builder = terminalBuilder()
178-
builder.having = having
179-
return builder
180-
})
181-
const forBuilder = terminalBuilder
182-
const forClause = vi.fn(forBuilder)
152+
/**
153+
* Records the call on the shared spy, honoring any per-test override; when the
154+
* spy still has its default implementation, builds the chain-local default.
155+
*/
156+
const spyOrDefault = (spy: ChainSpy, buildDefault: (...args: any[]) => unknown) =>
157+
vi.fn((...args: any[]) => {
158+
const result = spy(...args)
159+
return result === CHAIN_DEFAULT ? buildDefault(...args) : result
160+
})
183161

162+
// Shared spies: structural steps default to the sentinel (chain-local builders
163+
// take over); value terminals keep real defaults.
164+
const select = chainSpy()
165+
const selectDistinct = chainSpy()
166+
const selectDistinctOn = chainSpy()
167+
const from = chainSpy()
168+
const where = chainSpy()
169+
const limit = chainSpy()
170+
const offset = chainSpy()
171+
const orderBy = chainSpy()
172+
const groupBy = chainSpy()
173+
const having = chainSpy()
174+
const forClause = chainSpy()
175+
const innerJoin = chainSpy()
176+
const leftJoin = chainSpy()
177+
const insert = chainSpy()
178+
const update = chainSpy()
179+
const set = chainSpy()
180+
const del = chainSpy()
181+
const returning = vi.fn(() => Promise.resolve([] as unknown[]))
182+
const execute = vi.fn(() => Promise.resolve([] as unknown[]))
183+
const query = vi.fn(() => Promise.resolve([] as unknown[]))
184184
const onConflictDoUpdate = vi.fn(() => ({ returning }) as unknown as Promise<void>)
185185
const onConflictDoNothing = vi.fn(() => ({ returning }) as unknown as Promise<void>)
186+
const values = vi.fn(() => ({ returning, onConflictDoUpdate, onConflictDoNothing }))
187+
const transaction: ReturnType<typeof vi.fn> = vi.fn(
188+
async (cb: (tx: any) => unknown): Promise<unknown> => cb(dbChainMock.db)
189+
)
190+
191+
const rowsPromise = (rows: unknown[] | null) => Promise.resolve((rows ?? []) as unknown[])
192+
193+
// `.limit()` returns a builder that is awaitable and also exposes `.offset()`
194+
// for keyset/OFFSET paging (`.limit(n).offset(m)`).
195+
const limitBuilder = (rows: unknown[] | null) => {
196+
const thenable: any = rowsPromise(rows)
197+
thenable.offset = spyOrDefault(offset, () => rowsPromise(rows))
198+
return thenable
199+
}
186200

187-
const whereBuilder = () => {
188-
// Dequeue table-routed rows when the where clause materializes; every
189-
// downstream terminal (limit/orderBy/...) then resolves the same rows.
190-
activeRows = dequeueChainRows(activeTables)
191-
// Some call sites await the where directly (no limit/orderBy), so the
192-
// builder is itself a thenable.
193-
const thenable: any = chainRows()
194-
thenable.limit = limit
195-
thenable.orderBy = orderBy
201+
const terminalBuilder = (rows: unknown[] | null): any => {
202+
const thenable: any = rowsPromise(rows)
203+
thenable.limit = spyOrDefault(limit, () => limitBuilder(rows))
204+
thenable.orderBy = spyOrDefault(orderBy, () => terminalBuilder(rows))
196205
thenable.returning = returning
197-
thenable.groupBy = groupBy
198-
thenable.for = forClause
206+
thenable.groupBy = spyOrDefault(groupBy, () => {
207+
const builder = terminalBuilder(rows)
208+
builder.having = spyOrDefault(having, () => terminalBuilder(rows))
209+
return builder
210+
})
211+
thenable.for = spyOrDefault(forClause, () => terminalBuilder(rows))
199212
return thenable
200213
}
201-
const where = vi.fn(whereBuilder)
202214

203215
// The from/join builder is itself a thenable so `await db.select().from(t)`
204-
// (no where clause) also resolves table-routed rows. Each builder closes over
205-
// ITS chain's tables array, so builders constructed before an earlier one is
206-
// awaited still route to their own chain. Dequeue happens lazily at await
207-
// time, so a chain that continues into `.where()` never double-consumes.
208-
const joinBuilder = (
209-
tables: unknown[]
210-
): { where: typeof where; innerJoin: any; leftJoin: any; then: any } => ({
211-
where,
212-
innerJoin,
213-
leftJoin,
216+
// (no where clause) also resolves table-routed rows; dequeue happens lazily at
217+
// await (or where()) time, so a chain never double-consumes.
218+
const joinBuilder = (tables: unknown[]): any => ({
219+
where: spyOrDefault(where, () => terminalBuilder(dequeueChainRows(tables))),
220+
innerJoin: spyOrDefault(innerJoin, (table: unknown) => joinBuilder([...tables, table])),
221+
leftJoin: spyOrDefault(leftJoin, (table: unknown) => joinBuilder([...tables, table])),
214222
then: (onFulfilled?: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
215-
Promise.resolve((dequeueChainRows(tables) ?? []) as unknown[]).then(onFulfilled, onRejected),
223+
rowsPromise(dequeueChainRows(tables)).then(onFulfilled, onRejected),
216224
})
217-
const joinStep = (table?: unknown) => {
218-
activeTables.push(table)
219-
return joinBuilder(activeTables)
220-
}
221-
const innerJoin: ReturnType<typeof vi.fn> = vi.fn(joinStep)
222-
const leftJoin: ReturnType<typeof vi.fn> = vi.fn(joinStep)
223-
const from = vi.fn((table?: unknown) => {
224-
activeTables = [table]
225-
activeRows = null
226-
return joinBuilder(activeTables)
225+
226+
const selectBuilder = () => ({
227+
from: spyOrDefault(from, (table: unknown) => joinBuilder([table])),
227228
})
228229

229-
const select = vi.fn(() => ({ from }))
230-
const selectDistinct = vi.fn(() => ({ from }))
231-
const selectDistinctOn = vi.fn(() => ({ from }))
232-
const values = vi.fn(() => ({ returning, onConflictDoUpdate, onConflictDoNothing }))
233-
const insert = vi.fn(() => ({ values }))
234-
// Mutation chains clear the routing context so their `where()` never consumes
235-
// rows queued for a select.
236-
const mutationStep = <T>(next: T): T => {
237-
activeTables = []
238-
activeRows = null
239-
return next
240-
}
241-
const set = vi.fn(() => mutationStep({ where }))
242-
const update = vi.fn(() => mutationStep({ set }))
243-
const del = vi.fn(() => mutationStep({ where }))
244-
const query = vi.fn(() => Promise.resolve([] as unknown[]))
245-
const transaction: ReturnType<typeof vi.fn> = vi.fn(
246-
async (cb: (tx: any) => unknown): Promise<unknown> => cb(dbChainMock.db)
247-
)
230+
// Mutation chains route nothing: their where() resolves the plain default so a
231+
// mutation can never consume rows queued for a select.
232+
const mutationWhere = () => ({
233+
where: spyOrDefault(where, () => terminalBuilder(null)),
234+
})
248235

249236
export const dbChainMockFns = {
250237
select,
@@ -273,8 +260,8 @@ export const dbChainMockFns = {
273260
}
274261

275262
/**
276-
* Re-applies the default chain wiring to every `dbChainMockFns` entry and
277-
* clears all table-routed row queues. Call this in `beforeEach` (after
263+
* Restores every `dbChainMockFns` entry to its default wiring and clears all
264+
* table-routed row queues. Call this in `beforeEach` (after
278265
* `vi.clearAllMocks()`) if any test uses `mockReturnValue` /
279266
* `mockResolvedValue` (permanent overrides) or `queueTableRows` — this
280267
* guarantees the next test starts with fresh defaults.
@@ -284,57 +271,51 @@ export const dbChainMockFns = {
284271
*/
285272
export function resetDbChainMock(): void {
286273
tableRowQueues.clear()
287-
activeTables = []
288-
activeRows = null
289-
select.mockImplementation(() => ({ from }))
290-
selectDistinct.mockImplementation(() => ({ from }))
291-
selectDistinctOn.mockImplementation(() => ({ from }))
292-
from.mockImplementation((table?: unknown) => {
293-
activeTables = [table]
294-
activeRows = null
295-
return joinBuilder(activeTables)
296-
})
297-
innerJoin.mockImplementation(joinStep)
298-
leftJoin.mockImplementation(joinStep)
299-
where.mockImplementation(whereBuilder)
300-
insert.mockImplementation(() => ({ values }))
301-
values.mockImplementation(() => ({ returning, onConflictDoUpdate, onConflictDoNothing }))
302-
onConflictDoUpdate.mockImplementation(() => ({ returning }) as unknown as Promise<void>)
303-
onConflictDoNothing.mockImplementation(() => ({ returning }) as unknown as Promise<void>)
304-
update.mockImplementation(() => mutationStep({ set }))
305-
set.mockImplementation(() => mutationStep({ where }))
306-
del.mockImplementation(() => mutationStep({ where }))
307-
limit.mockImplementation(limitBuilder)
308-
offset.mockImplementation(chainRows)
309-
orderBy.mockImplementation(terminalBuilder)
274+
for (const spy of [
275+
select,
276+
selectDistinct,
277+
selectDistinctOn,
278+
from,
279+
where,
280+
limit,
281+
offset,
282+
orderBy,
283+
groupBy,
284+
having,
285+
forClause,
286+
innerJoin,
287+
leftJoin,
288+
insert,
289+
update,
290+
set,
291+
del,
292+
]) {
293+
spy.mockImplementation(() => CHAIN_DEFAULT)
294+
}
310295
returning.mockImplementation(() => Promise.resolve([] as unknown[]))
311-
having.mockImplementation(terminalBuilder)
312-
groupBy.mockImplementation(() => {
313-
const builder = terminalBuilder()
314-
builder.having = having
315-
return builder
316-
})
317296
execute.mockImplementation(() => Promise.resolve([] as unknown[]))
318297
query.mockImplementation(() => Promise.resolve([] as unknown[]))
319-
forClause.mockImplementation(forBuilder)
298+
onConflictDoUpdate.mockImplementation(() => ({ returning }) as unknown as Promise<void>)
299+
onConflictDoNothing.mockImplementation(() => ({ returning }) as unknown as Promise<void>)
300+
values.mockImplementation(() => ({ returning, onConflictDoUpdate, onConflictDoNothing }))
320301
transaction.mockImplementation(async (cb: (tx: typeof dbChainMock.db) => unknown) =>
321302
cb(dbChainMock.db)
322303
)
323304
}
324305

325306
/**
326307
* The single shared `@sim/db` mock instance backing BOTH `dbChainMock` and
327-
* `databaseMock`. Because every binding resolves to the same chain fns, a
308+
* `databaseMock`. Because every binding resolves to the same chain spies, a
328309
* module bound to either export behaves identically — there is exactly one
329310
* db-mock state to configure and reset.
330311
*/
331312
const dbInstance = {
332-
select,
333-
selectDistinct,
334-
selectDistinctOn,
335-
insert,
336-
update,
337-
delete: del,
313+
select: spyOrDefault(select, selectBuilder),
314+
selectDistinct: spyOrDefault(selectDistinct, selectBuilder),
315+
selectDistinctOn: spyOrDefault(selectDistinctOn, selectBuilder),
316+
insert: spyOrDefault(insert, () => ({ values })),
317+
update: spyOrDefault(update, () => ({ set: spyOrDefault(set, mutationWhere) })),
318+
delete: spyOrDefault(del, mutationWhere),
338319
execute,
339320
query,
340321
transaction,
@@ -360,7 +341,7 @@ export const dbChainMock = {
360341

361342
/**
362343
* Mock module for `@sim/db` installed globally in vitest.setup.ts. Shares its
363-
* `db` instance (and therefore all chain fns and table queues) with
344+
* `db` instance (and therefore all chain spies and table queues) with
364345
* `dbChainMock`; additionally exposes the `sql` template tag and operator
365346
* exports the real module provides.
366347
*

0 commit comments

Comments
 (0)