-
Notifications
You must be signed in to change notification settings - Fork 17
Expand file tree
/
Copy pathcollection-dispatch.test.ts
More file actions
489 lines (442 loc) · 15.7 KB
/
Copy pathcollection-dispatch.test.ts
File metadata and controls
489 lines (442 loc) · 15.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
import { AsyncIterableResult } from '@prisma-next/framework-components/runtime';
import type { RuntimeScope } from '@prisma-next/sql-relational-core/types';
import { describe, expect, it } from 'vitest';
import { dispatchCollectionRows, stitchIncludes } from '../src/collection-dispatch';
import type { IncludeExpr } from '../src/types';
import { emptyState } from '../src/types';
import { createCollectionFor } from './collection-fixtures';
import type { MockRuntime, TestContract } from './helpers';
import { createMockRuntime, getTestContract, withCapabilities } from './helpers';
function withSingleQueryCapabilities(contract: TestContract) {
return withCapabilities(contract, {
...contract.capabilities,
[contract.targetFamily]: {
...(contract.capabilities[contract.targetFamily] ?? {}),
jsonAgg: true,
},
[contract.target]: {
...(contract.capabilities[contract.target] ?? {}),
jsonAgg: true,
lateral: true,
},
});
}
/**
* Mirrors the shape produced by the contract emitter: capability flags
* nested under the family + target namespaces, with no top-level entries.
* Used to assert "single-query path is selected for an emitted-shape
* contract" — the regression scenario the principled namespaced lookup
* was introduced to handle.
*/
function withEmittedSqlCapabilities(contract: TestContract) {
return withCapabilities(contract, {
sql: { jsonAgg: true, returning: true },
postgres: { jsonAgg: true, lateral: true, returning: true },
});
}
function addConnection(
runtime: MockRuntime,
onRelease: () => void,
): MockRuntime & {
connection: () => Promise<{
execute: MockRuntime['execute'];
release: () => Promise<void>;
}>;
} {
return Object.assign(runtime, {
async connection() {
return {
execute: runtime.execute.bind(runtime),
async release() {
onRelease();
},
};
},
});
}
function cloneInclude(include: IncludeExpr, overrides: Partial<IncludeExpr>): IncludeExpr {
return {
...include,
...overrides,
};
}
function emptyScope(): RuntimeScope {
return {
execute() {
return new AsyncIterableResult((async function* () {})());
},
};
}
describe('collection-dispatch', () => {
it('dispatchCollectionRows() maps rows when includes are absent', async () => {
const { collection, runtime } = createCollectionFor('User');
runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'alice@example.com' }]]);
const rows = await dispatchCollectionRows<Record<string, unknown>>({
contract: collection.ctx.context.contract,
runtime,
state: collection.state,
tableName: collection.tableName,
modelName: collection.modelName,
}).toArray();
expect(rows).toEqual([{ id: 1, name: 'Alice', email: 'alice@example.com' }]);
});
it('dispatchCollectionRows() depth-1 include with emitted-shape capabilities fires a single SQL execution (regression guard for namespaced capability lookup)', async () => {
// Guards against regressing the fix that taught `selectIncludeStrategy`
// to read capability flags from the contract's `targetFamily` and
// `target` namespaces. Prior to that fix, every emitted contract fell
// back to multi-query for nested includes — silently, because
// functional correctness was unaffected. This test fails fast if the
// regression returns: an emitted-shape contract should resolve a
// depth-1 include in one SQL execution, not two.
const contract = withEmittedSqlCapabilities(getTestContract());
const { collection, runtime } = createCollectionFor('User', contract);
const scoped = collection.select('name').include('posts');
runtime.setNextResults([
[{ id: 1, name: 'Alice', posts: '[{"id":10,"title":"Post A","user_id":1,"views":3}]' }],
]);
const rows = await dispatchCollectionRows<Record<string, unknown>>({
contract,
runtime,
state: scoped.state,
tableName: scoped.tableName,
modelName: scoped.modelName,
}).toArray();
expect(rows).toEqual([
{ name: 'Alice', posts: [{ id: 10, title: 'Post A', userId: 1, views: 3 }] },
]);
// The point of the test: 1 execution, not N+1.
expect(runtime.executions).toHaveLength(1);
});
it('dispatchCollectionRows() single-query path returns empty rows and releases scope', async () => {
const contract = withSingleQueryCapabilities(getTestContract());
const { collection, runtime } = createCollectionFor('User', contract);
const scoped = collection.include('posts');
runtime.setNextResults([[]]);
let released = false;
const runtimeWithConnection = addConnection(runtime, () => {
released = true;
});
const rows = await dispatchCollectionRows<Record<string, unknown>>({
contract,
runtime: runtimeWithConnection,
state: scoped.state,
tableName: scoped.tableName,
modelName: scoped.modelName,
}).toArray();
expect(rows).toEqual([]);
expect(released).toBe(true);
});
it('dispatchCollectionRows() single-query path parses include payloads and strips hidden join columns', async () => {
const contract = withSingleQueryCapabilities(getTestContract());
const { collection, runtime } = createCollectionFor('User', contract);
const scoped = collection.select('name').include('posts');
runtime.setNextResults([
[
{
id: 1,
name: 'Alice',
posts: '[{"id":10,"title":"Post A","user_id":1,"views":3},42,null]',
},
{
id: 2,
name: 'Bob',
posts: 'not-json',
},
{
id: 3,
name: 'Cara',
posts: null,
},
{
id: 4,
name: 'Drew',
posts: '{"id":99}',
},
],
]);
const rows = await dispatchCollectionRows<Record<string, unknown>>({
contract,
runtime,
state: scoped.state,
tableName: scoped.tableName,
modelName: scoped.modelName,
}).toArray();
expect(rows).toEqual([
{
name: 'Alice',
posts: [{ id: 10, title: 'Post A', userId: 1, views: 3 }],
},
{
name: 'Bob',
posts: [],
},
{
name: 'Cara',
posts: [],
},
{
name: 'Drew',
posts: [],
},
]);
});
it('dispatchCollectionRows() single-query to-one include returns mapped row or null', async () => {
const contract = withSingleQueryCapabilities(getTestContract());
const { collection, runtime } = createCollectionFor('Post', contract);
const scoped = collection.select('title').include('author');
runtime.setNextResults([
[
{
user_id: 1,
title: 'Has Author',
author: '[{"id":1,"name":"Alice","email":"alice@example.com"}]',
},
{
user_id: null,
title: 'No Author',
author: '[]',
},
],
]);
const rows = await dispatchCollectionRows<Record<string, unknown>>({
contract,
runtime,
state: scoped.state,
tableName: scoped.tableName,
modelName: scoped.modelName,
}).toArray();
expect(rows).toEqual([
{
title: 'Has Author',
author: {
id: 1,
name: 'Alice',
email: 'alice@example.com',
},
},
{
title: 'No Author',
author: null,
},
]);
});
it('dispatchCollectionRows() multi-query path stitches includes, strips hidden fields, and releases scope', async () => {
// Force multi-query strategy by clearing capabilities. Otherwise
// the base test contract's postgres.lateral / postgres.jsonAgg
// would route to single-query lateral.
const contract = withCapabilities(getTestContract(), {});
const { collection, runtime } = createCollectionFor('User', contract);
const scoped = collection.select('name').include('posts', (posts) => posts.select('title'));
runtime.setNextResults([
[
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
],
[
{ user_id: 1, title: 'One' },
{ user_id: 1, title: 'Two' },
],
]);
let released = false;
const runtimeWithConnection = addConnection(runtime, () => {
released = true;
});
const rows = await dispatchCollectionRows<Record<string, unknown>>({
contract,
runtime: runtimeWithConnection,
state: scoped.state,
tableName: scoped.tableName,
modelName: scoped.modelName,
}).toArray();
expect(rows).toEqual([
{
name: 'Alice',
posts: [{ title: 'One' }, { title: 'Two' }],
},
{
name: 'Bob',
posts: [],
},
]);
expect(released).toBe(true);
});
it('dispatchCollectionRows() multi-query path handles empty parent result sets', async () => {
// Force multi-query strategy so the empty-parent early return inside
// `dispatchWithMultiQueryIncludes` is actually exercised. The base
// contract's postgres.lateral / postgres.jsonAgg would otherwise
// route to single-query lateral.
const contract = withCapabilities(getTestContract(), {});
const { collection, runtime } = createCollectionFor('User', contract);
const scoped = collection.include('posts');
runtime.setNextResults([[]]);
const rows = await dispatchCollectionRows<Record<string, unknown>>({
contract,
runtime,
state: scoped.state,
tableName: scoped.tableName,
modelName: scoped.modelName,
}).toArray();
expect(rows).toEqual([]);
});
it('stitchIncludes() assigns empty values for row, scalar, and combine descriptors', async () => {
const contract = getTestContract();
const { collection } = createCollectionFor('User', contract);
const rowInclude = collection.include('posts').state.includes[0]!;
const scalarInclude = collection.include('posts', (posts) => posts.sum('views' as never)).state
.includes[0]!;
const combineInclude = collection.include('posts', (posts) =>
posts.combine({
rows: posts.take(1),
total: posts.sum('views' as never),
}),
).state.includes[0]!;
const parentRows = [
{ raw: {}, mapped: {} as Record<string, unknown> },
{ raw: {}, mapped: {} as Record<string, unknown> },
];
await stitchIncludes(emptyScope(), contract, parentRows, [
cloneInclude(rowInclude, { relationName: 'rowBranch' }),
cloneInclude(scalarInclude, { relationName: 'scalarBranch' }),
cloneInclude(combineInclude, { relationName: 'combineBranch' }),
]);
expect(parentRows).toEqual([
{
raw: {},
mapped: {
rowBranch: [],
scalarBranch: null,
combineBranch: {
rows: [],
total: null,
},
},
},
{
raw: {},
mapped: {
rowBranch: [],
scalarBranch: null,
combineBranch: {
rows: [],
total: null,
},
},
},
]);
});
it('stitchIncludes() computes scalar aggregates with numeric coercion and unknown selectors', async () => {
const contract = getTestContract();
const runtime = createMockRuntime();
const baseInclude = createCollectionFor('User', contract).collection.include('posts').state
.includes[0]!;
const sumSelector = {
kind: 'includeScalar',
fn: 'sum',
column: 'views',
state: emptyState(),
} as IncludeExpr['scalar'];
const noColumnSelector = {
kind: 'includeScalar',
fn: 'sum',
state: emptyState(),
} as IncludeExpr['scalar'];
const unknownSelector = {
kind: 'includeScalar',
fn: 'median' as never,
column: 'views',
state: emptyState(),
} as unknown as IncludeExpr['scalar'];
runtime.setNextResults([
[
{ user_id: 1, views: 3 },
{ user_id: 1, views: 10n },
{ user_id: 1, views: '20' },
{ user_id: 1, views: 'bad' },
{ user_id: 1, views: null },
{ user_id: 1, views: {} },
{ user_id: 2, views: 'bad' },
],
[{ user_id: 1, views: 99 }],
[{ user_id: 1, views: 5 }],
]);
const parentRows = [
{ raw: { id: 1 }, mapped: {} as Record<string, unknown> },
{ raw: { id: 2 }, mapped: {} as Record<string, unknown> },
];
await stitchIncludes(runtime, contract, parentRows, [
cloneInclude(baseInclude, {
relationName: 'sumViews',
scalar: sumSelector,
}),
cloneInclude(baseInclude, {
relationName: 'noColumn',
scalar: noColumnSelector,
}),
cloneInclude(baseInclude, {
relationName: 'unknownFn',
scalar: unknownSelector,
}),
]);
expect(parentRows).toEqual([
{
raw: { id: 1 },
mapped: {
sumViews: 33,
noColumn: null,
unknownFn: null,
},
},
{
raw: { id: 2 },
mapped: {
sumViews: null,
noColumn: null,
unknownFn: null,
},
},
]);
});
it('stitchIncludes() returns null for empty to-one row includes', async () => {
const contract = getTestContract();
const include = createCollectionFor('Post', contract).collection.include('author').state
.includes[0]!;
const parentRows = [{ raw: {}, mapped: {} as Record<string, unknown> }];
await stitchIncludes(emptyScope(), contract, parentRows, [include]);
expect(parentRows[0]?.mapped['author']).toBeNull();
});
// ---------------------------------------------------------------------------
// Single-query include child-row codec decoding — DEFERRED follow-up.
//
// The three `it.skip` blocks below are placeholders for the case where the
// single-query include strategy (lateral / correlated jsonb_agg payload)
// routes embedded child rows through the codec registry and surfaces
// decoded values (or wrapped failures) on each child cell. The titles
// describe what each case would assert under the single-path always-await
// runtime; the bodies are stubbed and not carried over verbatim from any
// historical implementation.
//
// The deferral is structural: the current `dispatchCollectionRows`
// single-query path (packages/3-extensions/sql-orm-client/src/
// collection-dispatch.ts) only JSON.parses the include payload and
// applies field-name mapping; it does not invoke codec query-time methods
// on child cells (`rg 'codec\.(encode|decode)' packages/3-extensions/
// sql-orm-client/src` returns zero matches). Adding child-row codec
// decoding to the single-query include path is a separate piece of ORM
// work, orthogonal to the codec async-shape decision tracked in ADR 204.
// ---------------------------------------------------------------------------
it.skip('dispatchCollectionRows() single-query include decodes async child fields and validates decoded values', async () => {
// Activates when child-row codec decoding is added to the single-query
// include path; assertions will express the single-path always-await
// contract (plain decoded values, no Promises).
});
it.skip('dispatchCollectionRows() single-query include preserves JSON schema validation failures for async child decodes', async () => {
// Activates with the orm-include-aggregate-codec-dispatch follow-up;
// will assert that JSON schema validation failures on async child cells
// are reported via the runtime envelope (RUNTIME.VALIDATION_FAILED).
});
it.skip('dispatchCollectionRows() single-query include wraps async child decode failures with codec context', async () => {
// Activates with the orm-include-aggregate-codec-dispatch follow-up;
// will assert that decode rejections on child cells are wrapped with
// codec id + lane context (RUNTIME.DECODE_FAILED).
});
});