@opensaas/stack-core 0.19.0 → 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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +80 -0
- package/dist/access/index.d.ts +1 -1
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js.map +1 -1
- package/dist/access/types.d.ts +69 -2
- package/dist/access/types.d.ts.map +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +40 -14
- package/dist/context/index.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/query/index.d.ts +299 -0
- package/dist/query/index.d.ts.map +1 -0
- package/dist/query/index.js +255 -0
- package/dist/query/index.js.map +1 -0
- package/dist/query/index.test.d.ts +2 -0
- package/dist/query/index.test.d.ts.map +1 -0
- package/dist/query/index.test.js +632 -0
- package/dist/query/index.test.js.map +1 -0
- package/package.json +6 -6
- package/src/access/index.ts +3 -0
- package/src/access/types.ts +83 -2
- package/src/context/index.ts +57 -24
- package/src/index.ts +14 -0
- package/src/query/index.test.ts +830 -0
- package/src/query/index.ts +505 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
+
})
|