@opensaas/stack-core 0.19.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,830 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import {
3
+ defineFragment,
4
+ runQuery,
5
+ runQueryOne,
6
+ buildInclude,
7
+ pickFields,
8
+ isFragment,
9
+ } from './index.js'
10
+ import type { ResultOf, Fragment, FieldSelection, QueryRunnerContext } from './index.js'
11
+
12
+ // ─────────────────────────────────────────────────────────────
13
+ // Test model types (stand-ins for Prisma-generated types)
14
+ // ─────────────────────────────────────────────────────────────
15
+
16
+ type Tag = {
17
+ id: string
18
+ name: string
19
+ slug: string
20
+ }
21
+
22
+ type User = {
23
+ id: string
24
+ name: string
25
+ email: string
26
+ role: 'admin' | 'user'
27
+ bio: string | null
28
+ }
29
+
30
+ type Post = {
31
+ id: string
32
+ title: string
33
+ content: string | null
34
+ published: boolean
35
+ createdAt: Date
36
+ authorId: string | null
37
+ author: User | null
38
+ tags: Tag[]
39
+ }
40
+
41
+ type Comment = {
42
+ id: string
43
+ body: string
44
+ post: Post | null
45
+ author: User | null
46
+ }
47
+
48
+ // ─────────────────────────────────────────────────────────────
49
+ // Helpers for building a fake context.db delegate
50
+ // ─────────────────────────────────────────────────────────────
51
+
52
+ function makeDelegate(rows: unknown[], findFirstRow?: unknown) {
53
+ return {
54
+ findMany: vi.fn(async (_args?: unknown) => rows),
55
+ findFirst: vi.fn(async (_args?: unknown) => findFirstRow ?? rows[0] ?? null),
56
+ }
57
+ }
58
+
59
+ function makeContext(
60
+ delegates: Record<string, ReturnType<typeof makeDelegate>>,
61
+ ): QueryRunnerContext {
62
+ return { db: delegates }
63
+ }
64
+
65
+ // ─────────────────────────────────────────────────────────────
66
+ // defineFragment — construction
67
+ // ─────────────────────────────────────────────────────────────
68
+
69
+ describe('defineFragment', () => {
70
+ it('returns an object with _type: "fragment" and the field selection', () => {
71
+ const frag = defineFragment<User>()({ id: true, name: true } as const)
72
+
73
+ expect(frag._type).toBe('fragment')
74
+ expect(frag._fields).toEqual({ id: true, name: true })
75
+ })
76
+
77
+ it('accepts true for all field types', () => {
78
+ const frag = defineFragment<Post>()({
79
+ id: true,
80
+ title: true,
81
+ content: true,
82
+ published: true,
83
+ createdAt: true,
84
+ authorId: true,
85
+ } as const)
86
+
87
+ expect(Object.keys(frag._fields)).toEqual([
88
+ 'id',
89
+ 'title',
90
+ 'content',
91
+ 'published',
92
+ 'createdAt',
93
+ 'authorId',
94
+ ])
95
+ })
96
+
97
+ it('accepts a nested fragment for a relationship field', () => {
98
+ const userFrag = defineFragment<User>()({ id: true, name: true } as const)
99
+ const postFrag = defineFragment<Post>()({ id: true, author: userFrag } as const)
100
+
101
+ expect(postFrag._fields.author).toBe(userFrag)
102
+ })
103
+
104
+ it('accepts a nested fragment for a many relationship', () => {
105
+ const tagFrag = defineFragment<Tag>()({ id: true, name: true } as const)
106
+ const postFrag = defineFragment<Post>()({ id: true, tags: tagFrag } as const)
107
+
108
+ expect(postFrag._fields.tags).toBe(tagFrag)
109
+ })
110
+ })
111
+
112
+ // ─────────────────────────────────────────────────────────────
113
+ // ResultOf — static type tests (compile-time checks)
114
+ // ─────────────────────────────────────────────────────────────
115
+
116
+ describe('ResultOf (type-level checks)', () => {
117
+ it('scalar fragment produces a plain picked type', () => {
118
+ const frag = defineFragment<User>()({ id: true, name: true, email: true } as const)
119
+ // TypeScript compile check: ResultOf<typeof frag> must have id, name, email
120
+ type Result = ResultOf<typeof frag>
121
+ const r: Result = { id: '1', name: 'Alice', email: 'a@test.com' }
122
+ expect(r).toBeTruthy()
123
+ expect(frag._type).toBe('fragment')
124
+ })
125
+
126
+ it('nullable scalar fields are preserved', () => {
127
+ const frag = defineFragment<Post>()({ id: true, content: true } as const)
128
+ type Result = ResultOf<typeof frag>
129
+ // content should be string | null
130
+ const r: Result = { id: '1', content: null }
131
+ expect(r.content).toBeNull()
132
+ expect(frag._type).toBe('fragment')
133
+ })
134
+
135
+ it('nested fragment on a nullable relationship preserves null', () => {
136
+ const userFrag = defineFragment<User>()({ id: true, name: true } as const)
137
+ const postFrag = defineFragment<Post>()({ id: true, author: userFrag } as const)
138
+ type PostResult = ResultOf<typeof postFrag>
139
+ // author should be { id: string; name: string } | null
140
+ const r: PostResult = { id: '1', author: null }
141
+ expect(r.author).toBeNull()
142
+ expect(postFrag._type).toBe('fragment')
143
+ })
144
+
145
+ it('nested fragment on a many relationship produces an array', () => {
146
+ const tagFrag = defineFragment<Tag>()({ id: true, name: true } as const)
147
+ const postFrag = defineFragment<Post>()({ id: true, tags: tagFrag } as const)
148
+ type PostResult = ResultOf<typeof postFrag>
149
+ const r: PostResult = { id: '1', tags: [{ id: 't1', name: 'ts' }] }
150
+ expect(r.tags).toHaveLength(1)
151
+ expect(postFrag._type).toBe('fragment')
152
+ })
153
+
154
+ it('deep nesting (comment → post → author) resolves correctly', () => {
155
+ const userFrag = defineFragment<User>()({ id: true, name: true } as const)
156
+ const postFrag = defineFragment<Post>()({ id: true, title: true, author: userFrag } as const)
157
+ const commentFrag = defineFragment<Comment>()({
158
+ id: true,
159
+ body: true,
160
+ post: postFrag,
161
+ } as const)
162
+ type CommentResult = ResultOf<typeof commentFrag>
163
+ const r: CommentResult = {
164
+ id: 'c1',
165
+ body: 'hi',
166
+ post: {
167
+ id: 'p1',
168
+ title: 'Hello',
169
+ author: { id: 'u1', name: 'Alice' },
170
+ },
171
+ }
172
+ expect(r.post?.author?.name).toBe('Alice')
173
+ expect(commentFrag._type).toBe('fragment')
174
+ })
175
+ })
176
+
177
+ // ─────────────────────────────────────────────────────────────
178
+ // runQuery — runtime behaviour
179
+ // ─────────────────────────────────────────────────────────────
180
+
181
+ describe('runQuery', () => {
182
+ const userFrag = defineFragment<User>()({ id: true, name: true, email: true } as const)
183
+
184
+ const rawUsers: User[] = [
185
+ { id: 'u1', name: 'Alice', email: 'alice@test.com', role: 'admin', bio: null },
186
+ { id: 'u2', name: 'Bob', email: 'bob@test.com', role: 'user', bio: 'Hey there' },
187
+ ]
188
+
189
+ it('calls context.db[dbKey].findMany with no args when none supplied', async () => {
190
+ const delegate = makeDelegate(rawUsers)
191
+ const ctx = makeContext({ user: delegate })
192
+
193
+ await runQuery(ctx, 'User', userFrag)
194
+
195
+ expect(delegate.findMany).toHaveBeenCalledWith(undefined)
196
+ })
197
+
198
+ it('returns only the fields specified in the fragment', async () => {
199
+ const ctx = makeContext({ user: makeDelegate(rawUsers) })
200
+ const results = await runQuery(ctx, 'User', userFrag)
201
+
202
+ expect(results).toHaveLength(2)
203
+ // Only id, name, email — not role or bio
204
+ expect(results[0]).toEqual({ id: 'u1', name: 'Alice', email: 'alice@test.com' })
205
+ expect(results[1]).toEqual({ id: 'u2', name: 'Bob', email: 'bob@test.com' })
206
+ expect(results[0]).not.toHaveProperty('role')
207
+ expect(results[0]).not.toHaveProperty('bio')
208
+ })
209
+
210
+ it('passes where, orderBy, take, skip to findMany', async () => {
211
+ const delegate = makeDelegate(rawUsers)
212
+ const ctx = makeContext({ user: delegate })
213
+
214
+ await runQuery(ctx, 'User', userFrag, {
215
+ where: { role: 'admin' },
216
+ orderBy: { name: 'asc' },
217
+ take: 5,
218
+ skip: 2,
219
+ })
220
+
221
+ expect(delegate.findMany).toHaveBeenCalledWith({
222
+ where: { role: 'admin' },
223
+ orderBy: { name: 'asc' },
224
+ take: 5,
225
+ skip: 2,
226
+ })
227
+ })
228
+
229
+ it('converts PascalCase listKey to camelCase for db access', async () => {
230
+ const delegate = makeDelegate([])
231
+ const ctx = makeContext({ blogPost: delegate })
232
+
233
+ await runQuery(ctx, 'BlogPost', defineFragment<Post>()({ id: true } as const))
234
+
235
+ expect(delegate.findMany).toHaveBeenCalled()
236
+ })
237
+
238
+ it('returns an empty array when findMany returns nothing', async () => {
239
+ const ctx = makeContext({ user: makeDelegate([]) })
240
+ const results = await runQuery(ctx, 'User', userFrag)
241
+ expect(results).toEqual([])
242
+ })
243
+
244
+ describe('with nested fragment (relationship)', () => {
245
+ const tagFrag = defineFragment<Tag>()({ id: true, name: true } as const)
246
+ const postFrag = defineFragment<Post>()({
247
+ id: true,
248
+ title: true,
249
+ author: userFrag,
250
+ tags: tagFrag,
251
+ } as const)
252
+
253
+ const rawPosts = [
254
+ {
255
+ id: 'p1',
256
+ title: 'Hello World',
257
+ content: 'body',
258
+ published: true,
259
+ createdAt: new Date('2024-01-01'),
260
+ authorId: 'u1',
261
+ author: {
262
+ id: 'u1',
263
+ name: 'Alice',
264
+ email: 'alice@test.com',
265
+ role: 'admin',
266
+ bio: null,
267
+ },
268
+ tags: [
269
+ { id: 't1', name: 'TypeScript', slug: 'typescript' },
270
+ { id: 't2', name: 'Node', slug: 'node' },
271
+ ],
272
+ },
273
+ ]
274
+
275
+ it('passes include for relationship fields to findMany', async () => {
276
+ const delegate = makeDelegate(rawPosts)
277
+ const ctx = makeContext({ post: delegate })
278
+
279
+ await runQuery(ctx, 'Post', postFrag)
280
+
281
+ expect(delegate.findMany).toHaveBeenCalledWith(
282
+ expect.objectContaining({
283
+ include: { author: true, tags: true },
284
+ }),
285
+ )
286
+ })
287
+
288
+ it('picks only the selected nested fields', async () => {
289
+ const ctx = makeContext({ post: makeDelegate(rawPosts) })
290
+ const results = await runQuery(ctx, 'Post', postFrag)
291
+
292
+ expect(results[0].author).toEqual({ id: 'u1', name: 'Alice', email: 'alice@test.com' })
293
+ // role and bio are NOT selected in userFrag
294
+ expect(results[0].author).not.toHaveProperty('role')
295
+ expect(results[0].author).not.toHaveProperty('bio')
296
+ })
297
+
298
+ it('maps tag arrays and strips unselected fields', async () => {
299
+ const ctx = makeContext({ post: makeDelegate(rawPosts) })
300
+ const results = await runQuery(ctx, 'Post', postFrag)
301
+
302
+ // slug is not in tagFrag, so it must be absent
303
+ expect(results[0].tags[0]).not.toHaveProperty('slug')
304
+ expect(results[0].tags[0]).toEqual({ id: 't1', name: 'TypeScript' })
305
+ expect(results[0].tags[1]).toEqual({ id: 't2', name: 'Node' })
306
+ })
307
+ })
308
+
309
+ describe('with null relationship', () => {
310
+ it('preserves null for an unset relationship', async () => {
311
+ const authorFrag = defineFragment<User>()({ id: true, name: true } as const)
312
+ const postFrag = defineFragment<Post>()({ id: true, author: authorFrag } as const)
313
+
314
+ const rawPost = {
315
+ id: 'p2',
316
+ title: 'Draft',
317
+ content: null,
318
+ published: false,
319
+ createdAt: new Date(),
320
+ authorId: null,
321
+ author: null,
322
+ tags: [],
323
+ }
324
+
325
+ const ctx = makeContext({ post: makeDelegate([rawPost]) })
326
+ const results = await runQuery(ctx, 'Post', postFrag)
327
+
328
+ expect(results[0].author).toBeNull()
329
+ })
330
+ })
331
+
332
+ describe('deeply nested (three levels)', () => {
333
+ it('recursively builds include and picks fields', async () => {
334
+ const authorFrag = defineFragment<User>()({ id: true, name: true } as const)
335
+ const postFrag = defineFragment<Post>()({
336
+ id: true,
337
+ title: true,
338
+ author: authorFrag,
339
+ } as const)
340
+ const commentFrag = defineFragment<Comment>()({
341
+ id: true,
342
+ body: true,
343
+ post: postFrag,
344
+ } as const)
345
+
346
+ const raw = [
347
+ {
348
+ id: 'c1',
349
+ body: 'Nice post!',
350
+ post: {
351
+ id: 'p1',
352
+ title: 'Hello',
353
+ content: 'body',
354
+ published: true,
355
+ createdAt: new Date(),
356
+ authorId: 'u1',
357
+ author: { id: 'u1', name: 'Alice', email: 'a@t.com', role: 'user', bio: null },
358
+ tags: [],
359
+ },
360
+ author: null,
361
+ },
362
+ ]
363
+
364
+ const delegate = makeDelegate(raw)
365
+ const ctx = makeContext({ comment: delegate })
366
+
367
+ const results = await runQuery(ctx, 'Comment', commentFrag)
368
+
369
+ // include passed to findMany
370
+ expect(delegate.findMany).toHaveBeenCalledWith(
371
+ expect.objectContaining({
372
+ include: {
373
+ post: { include: { author: true } },
374
+ },
375
+ }),
376
+ )
377
+
378
+ // result shape
379
+ expect(results[0]).toEqual({
380
+ id: 'c1',
381
+ body: 'Nice post!',
382
+ post: {
383
+ id: 'p1',
384
+ title: 'Hello',
385
+ author: { id: 'u1', name: 'Alice' },
386
+ },
387
+ })
388
+ })
389
+ })
390
+ })
391
+
392
+ // ─────────────────────────────────────────────────────────────
393
+ // runQueryOne — runtime behaviour
394
+ // ─────────────────────────────────────────────────────────────
395
+
396
+ describe('runQueryOne', () => {
397
+ const userFrag = defineFragment<User>()({ id: true, name: true, email: true } as const)
398
+
399
+ const rawUser: User = {
400
+ id: 'u1',
401
+ name: 'Alice',
402
+ email: 'alice@test.com',
403
+ role: 'admin',
404
+ bio: null,
405
+ }
406
+
407
+ it('calls context.db[dbKey].findFirst with the where clause', async () => {
408
+ const delegate = makeDelegate([rawUser], rawUser)
409
+ const ctx = makeContext({ user: delegate })
410
+
411
+ await runQueryOne(ctx, 'User', userFrag, { id: 'u1' })
412
+
413
+ expect(delegate.findFirst).toHaveBeenCalledWith({ where: { id: 'u1' } })
414
+ })
415
+
416
+ it('returns only the fields specified in the fragment', async () => {
417
+ const ctx = makeContext({ user: makeDelegate([rawUser], rawUser) })
418
+ const result = await runQueryOne(ctx, 'User', userFrag, { id: 'u1' })
419
+
420
+ expect(result).toEqual({ id: 'u1', name: 'Alice', email: 'alice@test.com' })
421
+ expect(result).not.toHaveProperty('role')
422
+ expect(result).not.toHaveProperty('bio')
423
+ })
424
+
425
+ it('returns null when findFirst returns null', async () => {
426
+ const delegate = makeDelegate([], null)
427
+ const ctx = makeContext({ user: delegate })
428
+
429
+ const result = await runQueryOne(ctx, 'User', userFrag, { id: 'nope' })
430
+
431
+ expect(result).toBeNull()
432
+ })
433
+
434
+ it('returns null when findFirst returns undefined', async () => {
435
+ const delegate = {
436
+ findMany: vi.fn(async () => []),
437
+ findFirst: vi.fn(async () => null),
438
+ }
439
+ const ctx = makeContext({ user: delegate })
440
+
441
+ const result = await runQueryOne(ctx, 'User', userFrag, { id: 'nope' })
442
+
443
+ expect(result).toBeNull()
444
+ })
445
+
446
+ it('passes include for relationship fields to findFirst', async () => {
447
+ const authorFrag = defineFragment<User>()({ id: true, name: true } as const)
448
+ const postFrag = defineFragment<Post>()({ id: true, title: true, author: authorFrag } as const)
449
+
450
+ const rawPost = {
451
+ id: 'p1',
452
+ title: 'Hello',
453
+ content: null,
454
+ published: true,
455
+ createdAt: new Date(),
456
+ authorId: 'u1',
457
+ author: { id: 'u1', name: 'Alice', email: 'a@t.com', role: 'user', bio: null },
458
+ tags: [],
459
+ }
460
+
461
+ const delegate = makeDelegate([rawPost], rawPost)
462
+ const ctx = makeContext({ post: delegate })
463
+
464
+ const result = await runQueryOne(ctx, 'Post', postFrag, { id: 'p1' })
465
+
466
+ expect(delegate.findFirst).toHaveBeenCalledWith({
467
+ where: { id: 'p1' },
468
+ include: { author: true },
469
+ })
470
+ expect(result).toEqual({
471
+ id: 'p1',
472
+ title: 'Hello',
473
+ author: { id: 'u1', name: 'Alice' },
474
+ })
475
+ })
476
+
477
+ it('converts PascalCase listKey to camelCase', async () => {
478
+ const delegate = makeDelegate([], null)
479
+ const ctx = makeContext({ blogPost: delegate })
480
+
481
+ await runQueryOne(ctx, 'BlogPost', defineFragment<Post>()({ id: true } as const), {
482
+ id: 'p1',
483
+ })
484
+
485
+ expect(delegate.findFirst).toHaveBeenCalled()
486
+ })
487
+ })
488
+
489
+ // ─────────────────────────────────────────────────────────────
490
+ // Fragment composition — reusability
491
+ // ─────────────────────────────────────────────────────────────
492
+
493
+ describe('fragment composition', () => {
494
+ it('the same fragment can be reused in multiple parent fragments', async () => {
495
+ const userFrag = defineFragment<User>()({ id: true, name: true } as const)
496
+ const postFrag = defineFragment<Post>()({ id: true, author: userFrag } as const)
497
+ const commentFrag = defineFragment<Comment>()({ id: true, author: userFrag } as const)
498
+
499
+ // Both refer to the exact same userFrag instance
500
+ expect(postFrag._fields.author).toBe(userFrag)
501
+ expect(commentFrag._fields.author).toBe(userFrag)
502
+ })
503
+
504
+ it('composes three levels deep without mutation', async () => {
505
+ const userFrag = defineFragment<User>()({ id: true, name: true } as const)
506
+ const postFrag = defineFragment<Post>()({
507
+ id: true,
508
+ title: true,
509
+ author: userFrag,
510
+ } as const)
511
+ const commentFrag = defineFragment<Comment>()({
512
+ id: true,
513
+ body: true,
514
+ post: postFrag,
515
+ } as const)
516
+
517
+ // Verify structure without executing any query
518
+ expect(commentFrag._type).toBe('fragment')
519
+ const postFieldInComment = commentFrag._fields.post as Fragment<Post, FieldSelection<Post>>
520
+ expect(postFieldInComment._type).toBe('fragment')
521
+ const authorFieldInPost = postFieldInComment._fields.author as Fragment<
522
+ User,
523
+ FieldSelection<User>
524
+ >
525
+ expect(authorFieldInPost._fields).toEqual({ id: true, name: true })
526
+ })
527
+ })
528
+
529
+ // ─────────────────────────────────────────────────────────────
530
+ // isFragment — runtime guard
531
+ // ─────────────────────────────────────────────────────────────
532
+
533
+ describe('isFragment', () => {
534
+ it('returns true for a fragment created by defineFragment', () => {
535
+ const frag = defineFragment<User>()({ id: true } as const)
536
+ expect(isFragment(frag)).toBe(true)
537
+ })
538
+
539
+ it('returns false for a RelationSelector object', () => {
540
+ const frag = defineFragment<User>()({ id: true } as const)
541
+ const selector = { query: frag, where: { active: true } }
542
+ expect(isFragment(selector)).toBe(false)
543
+ })
544
+
545
+ it('returns false for primitives and null', () => {
546
+ expect(isFragment(true)).toBe(false)
547
+ expect(isFragment(null)).toBe(false)
548
+ expect(isFragment(undefined)).toBe(false)
549
+ expect(isFragment('fragment')).toBe(false)
550
+ expect(isFragment(42)).toBe(false)
551
+ })
552
+
553
+ it('returns false for a plain object without _type', () => {
554
+ expect(isFragment({ id: true })).toBe(false)
555
+ })
556
+ })
557
+
558
+ // ─────────────────────────────────────────────────────────────
559
+ // buildInclude — RelationSelector with filter args
560
+ // ─────────────────────────────────────────────────────────────
561
+
562
+ type Comment2 = {
563
+ id: string
564
+ body: string
565
+ approved: boolean
566
+ post: Post | null
567
+ }
568
+
569
+ type PostWithComments = {
570
+ id: string
571
+ title: string
572
+ comments: Comment2[]
573
+ }
574
+
575
+ describe('buildInclude with RelationSelector', () => {
576
+ it('generates a simple include for a shorthand fragment', () => {
577
+ const userFrag = defineFragment<User>()({ id: true, name: true } as const)
578
+ const postFrag = defineFragment<Post>()({ id: true, author: userFrag } as const)
579
+ const result = buildInclude(postFrag._fields as FieldSelection<unknown>)
580
+ expect(result).toEqual({ author: true })
581
+ })
582
+
583
+ it('generates a where-filtered include for a RelationSelector', () => {
584
+ const commentFrag = defineFragment<Comment2>()({ id: true, body: true } as const)
585
+ const postFrag = defineFragment<PostWithComments>()({
586
+ id: true,
587
+ comments: {
588
+ query: commentFrag,
589
+ where: { approved: true },
590
+ },
591
+ } as const)
592
+
593
+ const result = buildInclude(postFrag._fields as FieldSelection<unknown>)
594
+ expect(result).toEqual({
595
+ comments: { where: { approved: true } },
596
+ })
597
+ })
598
+
599
+ it('includes orderBy, take and skip in the nested include', () => {
600
+ const commentFrag = defineFragment<Comment2>()({ id: true } as const)
601
+ const postFrag = defineFragment<PostWithComments>()({
602
+ id: true,
603
+ comments: {
604
+ query: commentFrag,
605
+ where: { approved: true },
606
+ orderBy: { id: 'asc' as const },
607
+ take: 5,
608
+ skip: 10,
609
+ },
610
+ } as const)
611
+
612
+ const result = buildInclude(postFrag._fields as FieldSelection<unknown>)
613
+ expect(result).toEqual({
614
+ comments: {
615
+ where: { approved: true },
616
+ orderBy: { id: 'asc' },
617
+ take: 5,
618
+ skip: 10,
619
+ },
620
+ })
621
+ })
622
+
623
+ it('combines RelationSelector args with a nested include from the inner fragment', () => {
624
+ const userFrag = defineFragment<User>()({ id: true, name: true } as const)
625
+ const commentWithAuthorFrag = defineFragment<Comment>()({
626
+ id: true,
627
+ body: true,
628
+ author: userFrag,
629
+ } as const)
630
+ const postFrag = defineFragment<Post>()({
631
+ id: true,
632
+ // Using RelationSelector with nested include (Comment has author)
633
+ author: {
634
+ query: defineFragment<User>()({ id: true } as const),
635
+ where: { role: 'admin' },
636
+ },
637
+ } as const)
638
+
639
+ const result = buildInclude(postFrag._fields as FieldSelection<unknown>)
640
+ // author has where clause (no nested include needed since User scalar fields)
641
+ expect(result).toEqual({
642
+ author: { where: { role: 'admin' } },
643
+ })
644
+
645
+ // Separate test: RelationSelector where inner fragment has nested relationships
646
+ const commentSelector = defineFragment<Comment>()({
647
+ id: true,
648
+ author: userFrag,
649
+ } as const)
650
+ const result2 = buildInclude(commentWithAuthorFrag._fields as FieldSelection<unknown>)
651
+ expect(result2).toEqual({ author: true })
652
+ // Suppress unused variable warning
653
+ expect(commentSelector._type).toBe('fragment')
654
+ })
655
+
656
+ it('returns undefined when there are no relationship fields', () => {
657
+ const frag = defineFragment<User>()({ id: true, name: true, email: true } as const)
658
+ const result = buildInclude(frag._fields as FieldSelection<unknown>)
659
+ expect(result).toBeUndefined()
660
+ })
661
+ })
662
+
663
+ // ─────────────────────────────────────────────────────────────
664
+ // pickFields — RelationSelector branch
665
+ // ─────────────────────────────────────────────────────────────
666
+
667
+ describe('pickFields with RelationSelector', () => {
668
+ it('picks fields from a nested array using RelationSelector', () => {
669
+ const commentFrag = defineFragment<Comment2>()({ id: true, body: true } as const)
670
+ const postFrag = defineFragment<PostWithComments>()({
671
+ id: true,
672
+ comments: { query: commentFrag, where: { approved: true } },
673
+ } as const)
674
+
675
+ const raw = {
676
+ id: 'p1',
677
+ title: 'Hello',
678
+ comments: [
679
+ { id: 'c1', body: 'Great!', approved: true },
680
+ { id: 'c2', body: 'Nice', approved: false },
681
+ ],
682
+ }
683
+
684
+ const result = pickFields(raw, postFrag._fields)
685
+ expect(result).toEqual({
686
+ id: 'p1',
687
+ comments: [
688
+ { id: 'c1', body: 'Great!' },
689
+ { id: 'c2', body: 'Nice' },
690
+ ],
691
+ })
692
+ // title is not selected
693
+ expect(result).not.toHaveProperty('title')
694
+ // approved is not in commentFrag
695
+ expect((result.comments as unknown[])[0]).not.toHaveProperty('approved')
696
+ })
697
+
698
+ it('handles null relationship in RelationSelector', () => {
699
+ const userFrag = defineFragment<User>()({ id: true, name: true } as const)
700
+ const postFrag = defineFragment<Post>()({
701
+ id: true,
702
+ author: { query: userFrag, where: { role: 'admin' } },
703
+ } as const)
704
+
705
+ const raw = { id: 'p1', author: null }
706
+ const result = pickFields(raw, postFrag._fields)
707
+ expect(result).toEqual({ id: 'p1', author: null })
708
+ })
709
+ })
710
+
711
+ // ─────────────────────────────────────────────────────────────
712
+ // runQuery — RelationSelector with filter args
713
+ // ─────────────────────────────────────────────────────────────
714
+
715
+ describe('runQuery with RelationSelector', () => {
716
+ it('passes nested where/orderBy/take/skip to include entry', async () => {
717
+ const commentFrag = defineFragment<Comment2>()({ id: true, body: true } as const)
718
+ const postFrag = defineFragment<PostWithComments>()({
719
+ id: true,
720
+ title: true,
721
+ comments: {
722
+ query: commentFrag,
723
+ where: { approved: true },
724
+ orderBy: { id: 'asc' as const },
725
+ take: 3,
726
+ },
727
+ } as const)
728
+
729
+ const rawPosts = [
730
+ {
731
+ id: 'p1',
732
+ title: 'Hello',
733
+ comments: [{ id: 'c1', body: 'First!', approved: true }],
734
+ },
735
+ ]
736
+
737
+ const delegate = makeDelegate(rawPosts)
738
+ const ctx = makeContext({ postWithComments: delegate })
739
+
740
+ await runQuery(ctx, 'PostWithComments', postFrag)
741
+
742
+ expect(delegate.findMany).toHaveBeenCalledWith(
743
+ expect.objectContaining({
744
+ include: {
745
+ comments: { where: { approved: true }, orderBy: { id: 'asc' }, take: 3 },
746
+ },
747
+ }),
748
+ )
749
+ })
750
+
751
+ it('picks only fragment fields from nested items in RelationSelector results', async () => {
752
+ const commentFrag = defineFragment<Comment2>()({ id: true, body: true } as const)
753
+ const postFrag = defineFragment<PostWithComments>()({
754
+ id: true,
755
+ comments: { query: commentFrag, where: { approved: true } },
756
+ } as const)
757
+
758
+ const rawPosts = [
759
+ {
760
+ id: 'p1',
761
+ title: 'Hello',
762
+ comments: [
763
+ { id: 'c1', body: 'Yes!', approved: true },
764
+ { id: 'c2', body: 'No', approved: false },
765
+ ],
766
+ },
767
+ ]
768
+
769
+ const ctx = makeContext({ postWithComments: makeDelegate(rawPosts) })
770
+ const results = await runQuery(ctx, 'PostWithComments', postFrag)
771
+
772
+ expect(results[0]).toEqual({
773
+ id: 'p1',
774
+ comments: [
775
+ { id: 'c1', body: 'Yes!' },
776
+ { id: 'c2', body: 'No' },
777
+ ],
778
+ })
779
+ expect(results[0]).not.toHaveProperty('title')
780
+ })
781
+ })
782
+
783
+ // ─────────────────────────────────────────────────────────────
784
+ // Variables pattern — factory function
785
+ // ─────────────────────────────────────────────────────────────
786
+
787
+ describe('factory function (variables) pattern', () => {
788
+ it('creates different fragments with different runtime values', () => {
789
+ const commentFrag = defineFragment<Comment2>()({ id: true, body: true } as const)
790
+
791
+ function makePostFrag(approvedOnly: boolean) {
792
+ return defineFragment<PostWithComments>()({
793
+ id: true,
794
+ comments: {
795
+ query: commentFrag,
796
+ where: { approved: approvedOnly },
797
+ },
798
+ } as const)
799
+ }
800
+
801
+ const approvedFrag = makePostFrag(true)
802
+ const allFrag = makePostFrag(false)
803
+
804
+ const approvedInclude = buildInclude(approvedFrag._fields as FieldSelection<unknown>)
805
+ const allInclude = buildInclude(allFrag._fields as FieldSelection<unknown>)
806
+
807
+ expect(approvedInclude).toEqual({ comments: { where: { approved: true } } })
808
+ expect(allInclude).toEqual({ comments: { where: { approved: false } } })
809
+ })
810
+
811
+ it('ResultOf is the same shape regardless of runtime where values', () => {
812
+ const commentFrag = defineFragment<Comment2>()({ id: true, body: true } as const)
813
+
814
+ const makePostFrag = (status: boolean) =>
815
+ defineFragment<PostWithComments>()({
816
+ id: true,
817
+ comments: { query: commentFrag, where: { approved: status } },
818
+ } as const)
819
+
820
+ // Runtime usage (ensures the function is used, not just as a type)
821
+ const frag = makePostFrag(true)
822
+ expect(frag._type).toBe('fragment')
823
+
824
+ type PostData = ResultOf<ReturnType<typeof makePostFrag>>
825
+ // Compile-time check: PostData should have id and comments
826
+ const r: PostData = { id: 'p1', comments: [{ id: 'c1', body: 'hi' }] }
827
+ expect(r.id).toBe('p1')
828
+ expect(r.comments).toHaveLength(1)
829
+ })
830
+ })