@avelonjs/conformance 0.1.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/LICENSE +21 -0
- package/README.md +155 -0
- package/package.json +50 -0
- package/src/fakes/ai.ts +113 -0
- package/src/fakes/cache.ts +153 -0
- package/src/fakes/database.ts +612 -0
- package/src/fakes/flags.ts +67 -0
- package/src/fakes/identity.ts +253 -0
- package/src/fakes/logs.ts +101 -0
- package/src/fakes/mail.ts +140 -0
- package/src/fakes/notifications.ts +73 -0
- package/src/fakes/payments.ts +188 -0
- package/src/fakes/queue.ts +205 -0
- package/src/fakes/ratelimit.ts +143 -0
- package/src/fakes/realtime.ts +96 -0
- package/src/fakes/search.ts +127 -0
- package/src/fakes/social.ts +83 -0
- package/src/fakes/storage.ts +125 -0
- package/src/fakes/tokens.ts +106 -0
- package/src/harness.ts +103 -0
- package/src/index.ts +21 -0
- package/src/suites/ai.ts +167 -0
- package/src/suites/cache.ts +174 -0
- package/src/suites/database.ts +853 -0
- package/src/suites/flags.ts +80 -0
- package/src/suites/identity.ts +293 -0
- package/src/suites/index.ts +16 -0
- package/src/suites/logs.ts +116 -0
- package/src/suites/mail.ts +160 -0
- package/src/suites/notifications.ts +119 -0
- package/src/suites/payments.ts +200 -0
- package/src/suites/queue.ts +238 -0
- package/src/suites/ratelimit.ts +134 -0
- package/src/suites/realtime.ts +123 -0
- package/src/suites/search.ts +115 -0
- package/src/suites/social.ts +129 -0
- package/src/suites/storage.ts +158 -0
- package/src/suites/tokens.ts +105 -0
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
Conflict,
|
|
4
|
+
Invalid,
|
|
5
|
+
type DatabaseCapabilities,
|
|
6
|
+
type DatabaseDriver,
|
|
7
|
+
type TransactionSurface,
|
|
8
|
+
type Predicate,
|
|
9
|
+
type QueryIR,
|
|
10
|
+
type RelationLoad,
|
|
11
|
+
} from '@avelonjs/core'
|
|
12
|
+
import { assertCapabilitySurface, captureFailure, type SuiteContext } from '../harness'
|
|
13
|
+
|
|
14
|
+
type AssayDriver = DatabaseDriver<DatabaseCapabilities>
|
|
15
|
+
type Row = Record<string, unknown>
|
|
16
|
+
|
|
17
|
+
function query(overrides: Partial<QueryIR> = {}): QueryIR {
|
|
18
|
+
return {
|
|
19
|
+
table: 'assay_users',
|
|
20
|
+
mode: 'select',
|
|
21
|
+
select: '*',
|
|
22
|
+
where: [],
|
|
23
|
+
relations: [],
|
|
24
|
+
order: [],
|
|
25
|
+
...overrides,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function seed(driver: AssayDriver, table: string, values: Row | Row[]): Promise<void> {
|
|
30
|
+
await driver.execute(query({ table, mode: 'insert', select: [], values, returning: undefined }))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ids(result: { readonly rows: readonly Row[] }): unknown[] {
|
|
34
|
+
return result.rows.map((row) => row.id)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function selectIds(driver: AssayDriver, where: Predicate[]): Promise<unknown[]> {
|
|
38
|
+
return ids(
|
|
39
|
+
await driver.execute(
|
|
40
|
+
query({ select: ['id'], where, order: [{ column: 'id', direction: 'asc' }] }),
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function expectInvalid(operation: () => Promise<unknown>): Promise<Invalid> {
|
|
46
|
+
const error = await captureFailure(operation)
|
|
47
|
+
expect(error).toBeInstanceOf(Invalid)
|
|
48
|
+
if (!(error instanceof Invalid)) throw new Error('Expected Invalid after taxonomy assertion.')
|
|
49
|
+
expect(error.code).toBe('INVALID')
|
|
50
|
+
return error
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function expectConflict(operation: () => Promise<unknown>): Promise<Conflict> {
|
|
54
|
+
const error = await captureFailure(operation)
|
|
55
|
+
expect(error).toBeInstanceOf(Conflict)
|
|
56
|
+
if (!(error instanceof Conflict)) throw new Error('Expected Conflict after taxonomy assertion.')
|
|
57
|
+
expect(error.code).toBe('CONFLICT')
|
|
58
|
+
return error
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function relation(overrides: Partial<RelationLoad> = {}): RelationLoad {
|
|
62
|
+
return {
|
|
63
|
+
relation: 'posts',
|
|
64
|
+
kind: 'hasMany',
|
|
65
|
+
table: 'assay_posts',
|
|
66
|
+
localKey: 'id',
|
|
67
|
+
foreignKey: 'user_id',
|
|
68
|
+
select: '*',
|
|
69
|
+
where: null,
|
|
70
|
+
order: [],
|
|
71
|
+
relations: [],
|
|
72
|
+
...overrides,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function nestedRelation(depth: number): RelationLoad {
|
|
77
|
+
const current = relation({
|
|
78
|
+
relation: `level_${depth}`,
|
|
79
|
+
kind: 'hasMany',
|
|
80
|
+
table: 'assay_users',
|
|
81
|
+
localKey: 'id',
|
|
82
|
+
foreignKey: 'id',
|
|
83
|
+
})
|
|
84
|
+
return depth === 1 ? current : { ...current, relations: [nestedRelation(depth - 1)] }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function hasRoundTripCounter(driver: object): driver is { readonly roundTrips: number } {
|
|
88
|
+
return 'roundTrips' in driver && typeof driver.roundTrips === 'number'
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function hasTransactions(driver: AssayDriver): driver is AssayDriver & TransactionSurface {
|
|
92
|
+
return (
|
|
93
|
+
driver.capabilities.transactions && typeof Reflect.get(driver, 'transaction') === 'function'
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function assay<TDriver extends AssayDriver>(
|
|
98
|
+
context: SuiteContext<TDriver>,
|
|
99
|
+
name: string,
|
|
100
|
+
assertion: (driver: TDriver) => Promise<void> | void,
|
|
101
|
+
): void {
|
|
102
|
+
test(name, async () => {
|
|
103
|
+
const driver = await context.create()
|
|
104
|
+
try {
|
|
105
|
+
await assertion(driver)
|
|
106
|
+
} finally {
|
|
107
|
+
await context.cleanup?.(driver)
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Registers the portable database contract conformance suite.
|
|
114
|
+
*
|
|
115
|
+
* The context must provision these empty fixture tables before each test:
|
|
116
|
+
* `assay_users(id, email, name, age, nickname)`, `assay_profiles(id, user_id, bio)`,
|
|
117
|
+
* `assay_posts(id, user_id, title, score, published_at)`,
|
|
118
|
+
* `assay_comments(id, post_id, body, position)`, and
|
|
119
|
+
* `assay_reactions(id, comment_id, kind)`. User IDs and emails, profile user IDs, and every table's
|
|
120
|
+
* ID are unique. The `assay_echo` routine returns its argument unchanged. Empty `belongsTo` and
|
|
121
|
+
* `hasOne` relations are `null`; empty `hasMany` relations are `[]`.
|
|
122
|
+
*
|
|
123
|
+
* @param context Fresh driver instances and optional live-service cleanup.
|
|
124
|
+
*/
|
|
125
|
+
export function databaseSuite<TDriver extends AssayDriver>(context: SuiteContext<TDriver>): void {
|
|
126
|
+
describe(`database conformance: ${context.name}`, () => {
|
|
127
|
+
assay(context, 'executes select, count, insert, update, and delete modes', async (driver) => {
|
|
128
|
+
const returningIds: QueryIR['returning'] = driver.capabilities.returning ? ['id'] : undefined
|
|
129
|
+
const inserted = await driver.execute(
|
|
130
|
+
query({
|
|
131
|
+
mode: 'insert',
|
|
132
|
+
select: [],
|
|
133
|
+
values: [
|
|
134
|
+
{ id: 'u1', email: 'one@example.test', name: 'One', age: 10, nickname: null },
|
|
135
|
+
{ id: 'u2', email: 'two@example.test', name: 'Two', age: 20, nickname: 'second' },
|
|
136
|
+
],
|
|
137
|
+
returning: returningIds,
|
|
138
|
+
}),
|
|
139
|
+
)
|
|
140
|
+
expect(inserted).toEqual({
|
|
141
|
+
rows: driver.capabilities.returning ? [{ id: 'u1' }, { id: 'u2' }] : [],
|
|
142
|
+
affected: 2,
|
|
143
|
+
})
|
|
144
|
+
expect(inserted.count).toBeUndefined()
|
|
145
|
+
|
|
146
|
+
const selected = await driver.execute(
|
|
147
|
+
query({
|
|
148
|
+
select: ['id', 'name'],
|
|
149
|
+
where: [{ kind: 'compare', column: 'id', op: '=', value: 'u1' }],
|
|
150
|
+
}),
|
|
151
|
+
)
|
|
152
|
+
expect(selected).toEqual({ rows: [{ id: 'u1', name: 'One' }], affected: 0 })
|
|
153
|
+
|
|
154
|
+
const counted = await driver.execute(
|
|
155
|
+
query({
|
|
156
|
+
mode: 'count',
|
|
157
|
+
select: [],
|
|
158
|
+
where: [{ kind: 'compare', column: 'age', op: '>=', value: 10 }],
|
|
159
|
+
}),
|
|
160
|
+
)
|
|
161
|
+
// Count is scalar metadata: it neither selects rows nor affects rows.
|
|
162
|
+
expect(counted).toEqual({ rows: [], affected: 0, count: 2 })
|
|
163
|
+
|
|
164
|
+
const updated = await driver.execute(
|
|
165
|
+
query({
|
|
166
|
+
mode: 'update',
|
|
167
|
+
select: [],
|
|
168
|
+
where: [{ kind: 'compare', column: 'id', op: '=', value: 'u1' }],
|
|
169
|
+
values: { name: 'Updated' },
|
|
170
|
+
returning: driver.capabilities.returning ? ['id', 'name'] : undefined,
|
|
171
|
+
}),
|
|
172
|
+
)
|
|
173
|
+
expect(updated).toEqual({
|
|
174
|
+
rows: driver.capabilities.returning ? [{ id: 'u1', name: 'Updated' }] : [],
|
|
175
|
+
affected: 1,
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const deleted = await driver.execute(
|
|
179
|
+
query({
|
|
180
|
+
mode: 'delete',
|
|
181
|
+
select: [],
|
|
182
|
+
where: [{ kind: 'compare', column: 'id', op: '=', value: 'u2' }],
|
|
183
|
+
returning: returningIds,
|
|
184
|
+
}),
|
|
185
|
+
)
|
|
186
|
+
expect(deleted).toEqual({
|
|
187
|
+
rows: driver.capabilities.returning ? [{ id: 'u2' }] : [],
|
|
188
|
+
affected: 1,
|
|
189
|
+
})
|
|
190
|
+
expect(await selectIds(driver, [])).toEqual(['u1'])
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
assay(context, 'executes upsert wildcard and explicit update lists', async (driver) => {
|
|
194
|
+
if (!driver.capabilities.upsert) return
|
|
195
|
+
await seed(driver, 'assay_users', {
|
|
196
|
+
id: 'u1',
|
|
197
|
+
email: 'one@example.test',
|
|
198
|
+
name: 'Original',
|
|
199
|
+
age: 10,
|
|
200
|
+
nickname: null,
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
const wildcard = await driver.execute(
|
|
204
|
+
query({
|
|
205
|
+
mode: 'upsert',
|
|
206
|
+
select: [],
|
|
207
|
+
values: {
|
|
208
|
+
id: 'u1',
|
|
209
|
+
email: 'one@example.test',
|
|
210
|
+
name: 'Wildcard',
|
|
211
|
+
age: 20,
|
|
212
|
+
nickname: 'changed',
|
|
213
|
+
},
|
|
214
|
+
conflict: { columns: ['id'], update: '*' },
|
|
215
|
+
returning: driver.capabilities.returning ? '*' : undefined,
|
|
216
|
+
}),
|
|
217
|
+
)
|
|
218
|
+
expect(wildcard.affected).toBe(1)
|
|
219
|
+
if (driver.capabilities.returning)
|
|
220
|
+
expect(wildcard.rows).toEqual([
|
|
221
|
+
{
|
|
222
|
+
id: 'u1',
|
|
223
|
+
email: 'one@example.test',
|
|
224
|
+
name: 'Wildcard',
|
|
225
|
+
age: 20,
|
|
226
|
+
nickname: 'changed',
|
|
227
|
+
},
|
|
228
|
+
])
|
|
229
|
+
expect(
|
|
230
|
+
(
|
|
231
|
+
await driver.execute(
|
|
232
|
+
query({
|
|
233
|
+
where: [{ kind: 'compare', column: 'id', op: '=', value: 'u1' }],
|
|
234
|
+
}),
|
|
235
|
+
)
|
|
236
|
+
).rows,
|
|
237
|
+
).toEqual([
|
|
238
|
+
{
|
|
239
|
+
id: 'u1',
|
|
240
|
+
email: 'one@example.test',
|
|
241
|
+
name: 'Wildcard',
|
|
242
|
+
age: 20,
|
|
243
|
+
nickname: 'changed',
|
|
244
|
+
},
|
|
245
|
+
])
|
|
246
|
+
|
|
247
|
+
const explicit = await driver.execute(
|
|
248
|
+
query({
|
|
249
|
+
mode: 'upsert',
|
|
250
|
+
select: [],
|
|
251
|
+
values: {
|
|
252
|
+
id: 'u1',
|
|
253
|
+
email: 'replacement@example.test',
|
|
254
|
+
name: 'Explicit',
|
|
255
|
+
age: 99,
|
|
256
|
+
nickname: 'ignored',
|
|
257
|
+
},
|
|
258
|
+
conflict: { columns: ['id'], update: ['name'] },
|
|
259
|
+
returning: driver.capabilities.returning ? '*' : undefined,
|
|
260
|
+
}),
|
|
261
|
+
)
|
|
262
|
+
expect(explicit.affected).toBe(1)
|
|
263
|
+
if (driver.capabilities.returning)
|
|
264
|
+
expect(explicit.rows).toEqual([
|
|
265
|
+
{
|
|
266
|
+
id: 'u1',
|
|
267
|
+
email: 'one@example.test',
|
|
268
|
+
name: 'Explicit',
|
|
269
|
+
age: 20,
|
|
270
|
+
nickname: 'changed',
|
|
271
|
+
},
|
|
272
|
+
])
|
|
273
|
+
expect(
|
|
274
|
+
(
|
|
275
|
+
await driver.execute(
|
|
276
|
+
query({
|
|
277
|
+
where: [{ kind: 'compare', column: 'id', op: '=', value: 'u1' }],
|
|
278
|
+
}),
|
|
279
|
+
)
|
|
280
|
+
).rows,
|
|
281
|
+
).toEqual([
|
|
282
|
+
{
|
|
283
|
+
id: 'u1',
|
|
284
|
+
email: 'one@example.test',
|
|
285
|
+
name: 'Explicit',
|
|
286
|
+
age: 20,
|
|
287
|
+
nickname: 'changed',
|
|
288
|
+
},
|
|
289
|
+
])
|
|
290
|
+
|
|
291
|
+
const created = await driver.execute(
|
|
292
|
+
query({
|
|
293
|
+
mode: 'upsert',
|
|
294
|
+
select: [],
|
|
295
|
+
values: {
|
|
296
|
+
id: 'u2',
|
|
297
|
+
email: 'two@example.test',
|
|
298
|
+
name: 'Created',
|
|
299
|
+
age: 30,
|
|
300
|
+
nickname: null,
|
|
301
|
+
},
|
|
302
|
+
conflict: { columns: ['id'], update: '*' },
|
|
303
|
+
returning: driver.capabilities.returning ? ['id'] : undefined,
|
|
304
|
+
}),
|
|
305
|
+
)
|
|
306
|
+
expect(created).toEqual({
|
|
307
|
+
rows: driver.capabilities.returning ? [{ id: 'u2' }] : [],
|
|
308
|
+
affected: 1,
|
|
309
|
+
})
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
assay(context, 'implements every comparison operator', async (driver) => {
|
|
313
|
+
await seed(driver, 'assay_users', [
|
|
314
|
+
{ id: 'u1', email: 'one@example.test', name: 'Alpha', age: 10, nickname: null },
|
|
315
|
+
{ id: 'u2', email: 'two@example.test', name: 'alpine', age: 20, nickname: null },
|
|
316
|
+
{ id: 'u3', email: 'three@example.test', name: 'Beta', age: 30, nickname: null },
|
|
317
|
+
])
|
|
318
|
+
const cases: readonly [QueryIRCompareOp, unknown, readonly string[]][] = [
|
|
319
|
+
['=', 20, ['u2']],
|
|
320
|
+
['!=', 20, ['u1', 'u3']],
|
|
321
|
+
['<', 20, ['u1']],
|
|
322
|
+
['<=', 20, ['u1', 'u2']],
|
|
323
|
+
['>', 20, ['u3']],
|
|
324
|
+
['>=', 20, ['u2', 'u3']],
|
|
325
|
+
]
|
|
326
|
+
for (const [op, value, expected] of cases) {
|
|
327
|
+
expect(await selectIds(driver, [{ kind: 'compare', column: 'age', op, value }])).toEqual([
|
|
328
|
+
...expected,
|
|
329
|
+
])
|
|
330
|
+
}
|
|
331
|
+
expect(
|
|
332
|
+
await selectIds(driver, [{ kind: 'compare', column: 'name', op: 'like', value: 'Al%' }]),
|
|
333
|
+
).toEqual(['u1'])
|
|
334
|
+
expect(
|
|
335
|
+
await selectIds(driver, [{ kind: 'compare', column: 'name', op: 'ilike', value: 'AL%' }]),
|
|
336
|
+
).toEqual(['u1', 'u2'])
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
assay(context, 'implements null and IN predicates including negation', async (driver) => {
|
|
340
|
+
await seed(driver, 'assay_users', [
|
|
341
|
+
{ id: 'u1', email: 'one@example.test', name: 'One', age: 10, nickname: null },
|
|
342
|
+
{ id: 'u2', email: 'two@example.test', name: 'Two', age: 20, nickname: 'x' },
|
|
343
|
+
{ id: 'u3', email: 'three@example.test', name: 'Three', age: 30, nickname: 'y' },
|
|
344
|
+
{ id: 'u4', email: 'four@example.test', name: 'Four', age: 40, nickname: null },
|
|
345
|
+
])
|
|
346
|
+
expect(
|
|
347
|
+
await selectIds(driver, [{ kind: 'null', column: 'nickname', negated: false }]),
|
|
348
|
+
).toEqual(['u1', 'u4'])
|
|
349
|
+
expect(
|
|
350
|
+
await selectIds(driver, [{ kind: 'null', column: 'nickname', negated: true }]),
|
|
351
|
+
).toEqual(['u2', 'u3'])
|
|
352
|
+
expect(
|
|
353
|
+
await selectIds(driver, [{ kind: 'compare', column: 'nickname', op: '!=', value: 'x' }]),
|
|
354
|
+
).toEqual(['u3'])
|
|
355
|
+
expect(
|
|
356
|
+
await selectIds(driver, [
|
|
357
|
+
{ kind: 'in', column: 'nickname', values: ['x', 'y'], negated: false },
|
|
358
|
+
]),
|
|
359
|
+
).toEqual(['u2', 'u3'])
|
|
360
|
+
expect(
|
|
361
|
+
await selectIds(driver, [{ kind: 'in', column: 'nickname', values: ['x'], negated: true }]),
|
|
362
|
+
).toEqual(['u3'])
|
|
363
|
+
expect(
|
|
364
|
+
await selectIds(driver, [{ kind: 'in', column: 'id', values: [], negated: false }]),
|
|
365
|
+
).toEqual([])
|
|
366
|
+
expect(
|
|
367
|
+
await selectIds(driver, [{ kind: 'in', column: 'id', values: [], negated: true }]),
|
|
368
|
+
).toEqual(['u1', 'u2', 'u3', 'u4'])
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
assay(context, 'ANDs separate where entries', async (driver) => {
|
|
372
|
+
await seed(driver, 'assay_users', [
|
|
373
|
+
{ id: 'u1', email: 'one@example.test', name: 'One', age: 10, nickname: null },
|
|
374
|
+
{ id: 'u2', email: 'two@example.test', name: 'Two', age: 20, nickname: null },
|
|
375
|
+
{ id: 'u3', email: 'three@example.test', name: 'Three', age: 30, nickname: null },
|
|
376
|
+
])
|
|
377
|
+
expect(
|
|
378
|
+
await selectIds(driver, [
|
|
379
|
+
{ kind: 'compare', column: 'age', op: '>=', value: 20 },
|
|
380
|
+
{ kind: 'compare', column: 'age', op: '<', value: 30 },
|
|
381
|
+
]),
|
|
382
|
+
).toEqual(['u2'])
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
assay(context, 'ORs branches within an or predicate', async (driver) => {
|
|
386
|
+
await seed(driver, 'assay_users', [
|
|
387
|
+
{ id: 'u1', email: 'one@example.test', name: 'One', age: 10, nickname: null },
|
|
388
|
+
{ id: 'u2', email: 'two@example.test', name: 'Two', age: 20, nickname: null },
|
|
389
|
+
{ id: 'u3', email: 'three@example.test', name: 'Three', age: 30, nickname: null },
|
|
390
|
+
])
|
|
391
|
+
expect(
|
|
392
|
+
await selectIds(driver, [
|
|
393
|
+
{
|
|
394
|
+
kind: 'or',
|
|
395
|
+
predicates: [
|
|
396
|
+
{ kind: 'compare', column: 'id', op: '=', value: 'u1' },
|
|
397
|
+
{ kind: 'compare', column: 'id', op: '=', value: 'u3' },
|
|
398
|
+
],
|
|
399
|
+
},
|
|
400
|
+
]),
|
|
401
|
+
).toEqual(['u1', 'u3'])
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
assay(context, 'ANDs the ward with query predicates', async (driver) => {
|
|
405
|
+
await seed(driver, 'assay_users', [
|
|
406
|
+
{ id: 'u1', email: 'one@example.test', name: 'One', age: 10, nickname: null },
|
|
407
|
+
{ id: 'u2', email: 'two@example.test', name: 'Two', age: 20, nickname: null },
|
|
408
|
+
{ id: 'u3', email: 'three@example.test', name: 'Three', age: 30, nickname: null },
|
|
409
|
+
])
|
|
410
|
+
const result = await driver.execute(
|
|
411
|
+
query({
|
|
412
|
+
select: ['id'],
|
|
413
|
+
where: [{ kind: 'compare', column: 'age', op: '>=', value: 20 }],
|
|
414
|
+
ward: { kind: 'compare', column: 'age', op: '<', value: 30 },
|
|
415
|
+
}),
|
|
416
|
+
)
|
|
417
|
+
expect(ids(result)).toEqual(['u2'])
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
assay(
|
|
421
|
+
context,
|
|
422
|
+
'applies empty boolean identities and folds NOT over constants',
|
|
423
|
+
async (driver) => {
|
|
424
|
+
await seed(driver, 'assay_users', {
|
|
425
|
+
id: 'u1',
|
|
426
|
+
email: 'one@example.test',
|
|
427
|
+
name: 'One',
|
|
428
|
+
age: 10,
|
|
429
|
+
nickname: null,
|
|
430
|
+
})
|
|
431
|
+
expect(await selectIds(driver, [{ kind: 'and', predicates: [] }])).toEqual(['u1'])
|
|
432
|
+
expect(await selectIds(driver, [{ kind: 'or', predicates: [] }])).toEqual([])
|
|
433
|
+
expect(
|
|
434
|
+
await selectIds(driver, [{ kind: 'not', predicate: { kind: 'const', value: false } }]),
|
|
435
|
+
).toEqual(['u1'])
|
|
436
|
+
expect(
|
|
437
|
+
await selectIds(driver, [{ kind: 'not', predicate: { kind: 'const', value: true } }]),
|
|
438
|
+
).toEqual([])
|
|
439
|
+
},
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
assay(
|
|
443
|
+
context,
|
|
444
|
+
'short-circuits constant false without a backing-store round trip',
|
|
445
|
+
async (driver) => {
|
|
446
|
+
await seed(driver, 'assay_users', {
|
|
447
|
+
id: 'u1',
|
|
448
|
+
email: 'one@example.test',
|
|
449
|
+
name: 'One',
|
|
450
|
+
age: 10,
|
|
451
|
+
nickname: null,
|
|
452
|
+
})
|
|
453
|
+
const beforeCount = hasRoundTripCounter(driver) ? driver.roundTrips : undefined
|
|
454
|
+
const counted = await driver.execute(
|
|
455
|
+
query({ mode: 'count', select: [], where: [{ kind: 'const', value: false }] }),
|
|
456
|
+
)
|
|
457
|
+
expect(counted).toEqual({ rows: [], affected: 0, count: 0 })
|
|
458
|
+
if (beforeCount !== undefined && hasRoundTripCounter(driver)) {
|
|
459
|
+
expect(driver.roundTrips).toBe(beforeCount)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const beforeSelect = hasRoundTripCounter(driver) ? driver.roundTrips : undefined
|
|
463
|
+
const selected = await driver.execute(query({ ward: { kind: 'const', value: false } }))
|
|
464
|
+
expect(selected).toEqual({ rows: [], affected: 0 })
|
|
465
|
+
if (beforeSelect !== undefined && hasRoundTripCounter(driver)) {
|
|
466
|
+
expect(driver.roundTrips).toBe(beforeSelect)
|
|
467
|
+
}
|
|
468
|
+
// A live driver proves the same property with its transport spy or query log: no statement may
|
|
469
|
+
// appear between the pre-call and post-call markers.
|
|
470
|
+
},
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
assay(
|
|
474
|
+
context,
|
|
475
|
+
'honors order, null placement, projection, limit, and offset',
|
|
476
|
+
async (driver) => {
|
|
477
|
+
await seed(driver, 'assay_users', [
|
|
478
|
+
{ id: 'u1', email: 'one@example.test', name: 'One', age: 10, nickname: null },
|
|
479
|
+
{ id: 'u2', email: 'two@example.test', name: 'Two', age: 20, nickname: 'alpha' },
|
|
480
|
+
{ id: 'u3', email: 'three@example.test', name: 'Three', age: 30, nickname: 'beta' },
|
|
481
|
+
])
|
|
482
|
+
const page = await driver.execute(
|
|
483
|
+
query({
|
|
484
|
+
select: ['id', 'nickname'],
|
|
485
|
+
order: [{ column: 'nickname', direction: 'asc', nulls: 'last' }],
|
|
486
|
+
offset: 1,
|
|
487
|
+
limit: 1,
|
|
488
|
+
}),
|
|
489
|
+
)
|
|
490
|
+
expect(page.rows).toEqual([{ id: 'u3', nickname: 'beta' }])
|
|
491
|
+
const nullsFirst = await driver.execute(
|
|
492
|
+
query({
|
|
493
|
+
select: ['id'],
|
|
494
|
+
order: [{ column: 'nickname', direction: 'desc', nulls: 'first' }],
|
|
495
|
+
}),
|
|
496
|
+
)
|
|
497
|
+
expect(ids(nullsFirst)).toEqual(['u1', 'u3', 'u2'])
|
|
498
|
+
},
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
assay(context, 'loads belongsTo, hasOne, and hasMany relations', async (driver) => {
|
|
502
|
+
if (driver.capabilities.maxRelationDepth < 1) return
|
|
503
|
+
await seed(driver, 'assay_users', [
|
|
504
|
+
{ id: 'u1', email: 'one@example.test', name: 'One', age: 10, nickname: null },
|
|
505
|
+
{ id: 'u2', email: 'two@example.test', name: 'Two', age: 20, nickname: null },
|
|
506
|
+
])
|
|
507
|
+
await seed(driver, 'assay_profiles', { id: 'p1', user_id: 'u1', bio: 'Profile one' })
|
|
508
|
+
await seed(driver, 'assay_posts', [
|
|
509
|
+
{ id: 'post1', user_id: 'u1', title: 'First', score: 1, published_at: null },
|
|
510
|
+
{ id: 'post2', user_id: 'u1', title: 'Second', score: 2, published_at: null },
|
|
511
|
+
])
|
|
512
|
+
|
|
513
|
+
const post = await driver.execute(
|
|
514
|
+
query({
|
|
515
|
+
table: 'assay_posts',
|
|
516
|
+
select: ['id'],
|
|
517
|
+
where: [{ kind: 'compare', column: 'id', op: '=', value: 'post1' }],
|
|
518
|
+
relations: [
|
|
519
|
+
relation({
|
|
520
|
+
relation: 'author',
|
|
521
|
+
kind: 'belongsTo',
|
|
522
|
+
table: 'assay_users',
|
|
523
|
+
localKey: 'user_id',
|
|
524
|
+
foreignKey: 'id',
|
|
525
|
+
select: ['id', 'name'],
|
|
526
|
+
}),
|
|
527
|
+
],
|
|
528
|
+
}),
|
|
529
|
+
)
|
|
530
|
+
expect(post.rows).toEqual([{ id: 'post1', author: { id: 'u1', name: 'One' } }])
|
|
531
|
+
|
|
532
|
+
const user = await driver.execute(
|
|
533
|
+
query({
|
|
534
|
+
select: ['id'],
|
|
535
|
+
where: [{ kind: 'compare', column: 'id', op: '=', value: 'u1' }],
|
|
536
|
+
relations: [
|
|
537
|
+
relation({
|
|
538
|
+
relation: 'profile',
|
|
539
|
+
kind: 'hasOne',
|
|
540
|
+
table: 'assay_profiles',
|
|
541
|
+
foreignKey: 'user_id',
|
|
542
|
+
select: ['bio'],
|
|
543
|
+
}),
|
|
544
|
+
relation({ select: ['id'], order: [{ column: 'id', direction: 'asc' }] }),
|
|
545
|
+
],
|
|
546
|
+
}),
|
|
547
|
+
)
|
|
548
|
+
expect(user.rows).toEqual([
|
|
549
|
+
{
|
|
550
|
+
id: 'u1',
|
|
551
|
+
profile: { bio: 'Profile one' },
|
|
552
|
+
posts: [{ id: 'post1' }, { id: 'post2' }],
|
|
553
|
+
},
|
|
554
|
+
])
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
assay(
|
|
558
|
+
context,
|
|
559
|
+
'returns null for empty singular relations and [] for empty hasMany relations',
|
|
560
|
+
async (driver) => {
|
|
561
|
+
if (driver.capabilities.maxRelationDepth < 1) return
|
|
562
|
+
await seed(driver, 'assay_users', [
|
|
563
|
+
{ id: 'u1', email: 'one@example.test', name: 'One', age: 10, nickname: null },
|
|
564
|
+
{ id: 'u2', email: 'two@example.test', name: 'Two', age: 20, nickname: null },
|
|
565
|
+
])
|
|
566
|
+
await seed(driver, 'assay_posts', {
|
|
567
|
+
id: 'post1',
|
|
568
|
+
user_id: 'u2',
|
|
569
|
+
title: 'Second user post',
|
|
570
|
+
score: 1,
|
|
571
|
+
published_at: null,
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
const user = await driver.execute(
|
|
575
|
+
query({
|
|
576
|
+
select: ['id'],
|
|
577
|
+
where: [{ kind: 'compare', column: 'id', op: '=', value: 'u1' }],
|
|
578
|
+
relations: [
|
|
579
|
+
relation({
|
|
580
|
+
relation: 'profile',
|
|
581
|
+
kind: 'hasOne',
|
|
582
|
+
table: 'assay_profiles',
|
|
583
|
+
foreignKey: 'user_id',
|
|
584
|
+
select: ['bio'],
|
|
585
|
+
}),
|
|
586
|
+
relation({ select: ['id'] }),
|
|
587
|
+
],
|
|
588
|
+
}),
|
|
589
|
+
)
|
|
590
|
+
const post = await driver.execute(
|
|
591
|
+
query({
|
|
592
|
+
table: 'assay_posts',
|
|
593
|
+
select: ['id'],
|
|
594
|
+
where: [{ kind: 'compare', column: 'id', op: '=', value: 'post1' }],
|
|
595
|
+
relations: [
|
|
596
|
+
relation({
|
|
597
|
+
relation: 'author',
|
|
598
|
+
kind: 'belongsTo',
|
|
599
|
+
table: 'assay_users',
|
|
600
|
+
localKey: 'user_id',
|
|
601
|
+
foreignKey: 'id',
|
|
602
|
+
select: ['id'],
|
|
603
|
+
where: { kind: 'compare', column: 'id', op: '=', value: 'missing' },
|
|
604
|
+
}),
|
|
605
|
+
],
|
|
606
|
+
}),
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
expect({ user: user.rows, post: post.rows }).toEqual({
|
|
610
|
+
user: [{ id: 'u1', profile: null, posts: [] }],
|
|
611
|
+
post: [{ id: 'post1', author: null }],
|
|
612
|
+
})
|
|
613
|
+
},
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
assay(
|
|
617
|
+
context,
|
|
618
|
+
'loads nested relations and applies relation limits per parent',
|
|
619
|
+
async (driver) => {
|
|
620
|
+
if (driver.capabilities.maxRelationDepth < 2) return
|
|
621
|
+
await seed(driver, 'assay_users', [
|
|
622
|
+
{ id: 'u1', email: 'one@example.test', name: 'One', age: 10, nickname: null },
|
|
623
|
+
{ id: 'u2', email: 'two@example.test', name: 'Two', age: 20, nickname: null },
|
|
624
|
+
])
|
|
625
|
+
await seed(driver, 'assay_posts', [
|
|
626
|
+
{ id: 'post1', user_id: 'u1', title: 'First', score: 1, published_at: null },
|
|
627
|
+
{ id: 'post2', user_id: 'u1', title: 'Second', score: 2, published_at: null },
|
|
628
|
+
{ id: 'post3', user_id: 'u2', title: 'Third', score: 3, published_at: null },
|
|
629
|
+
{ id: 'post4', user_id: 'u2', title: 'Fourth', score: 4, published_at: null },
|
|
630
|
+
])
|
|
631
|
+
await seed(driver, 'assay_comments', [
|
|
632
|
+
{ id: 'c1', post_id: 'post2', body: 'Earlier', position: 1 },
|
|
633
|
+
{ id: 'c2', post_id: 'post2', body: 'Later', position: 2 },
|
|
634
|
+
{ id: 'c3', post_id: 'post4', body: 'Other', position: 1 },
|
|
635
|
+
])
|
|
636
|
+
const comments = relation({
|
|
637
|
+
relation: 'comments',
|
|
638
|
+
table: 'assay_comments',
|
|
639
|
+
localKey: 'id',
|
|
640
|
+
foreignKey: 'post_id',
|
|
641
|
+
select: ['id'],
|
|
642
|
+
order: [{ column: 'position', direction: 'desc' }],
|
|
643
|
+
limit: 1,
|
|
644
|
+
})
|
|
645
|
+
const result = await driver.execute(
|
|
646
|
+
query({
|
|
647
|
+
select: ['id'],
|
|
648
|
+
order: [{ column: 'id', direction: 'asc' }],
|
|
649
|
+
relations: [
|
|
650
|
+
relation({
|
|
651
|
+
select: ['id'],
|
|
652
|
+
order: [{ column: 'score', direction: 'desc' }],
|
|
653
|
+
limit: 1,
|
|
654
|
+
relations: [comments],
|
|
655
|
+
}),
|
|
656
|
+
],
|
|
657
|
+
}),
|
|
658
|
+
)
|
|
659
|
+
expect(result.rows).toEqual([
|
|
660
|
+
{ id: 'u1', posts: [{ id: 'post2', comments: [{ id: 'c2' }] }] },
|
|
661
|
+
{ id: 'u2', posts: [{ id: 'post4', comments: [{ id: 'c3' }] }] },
|
|
662
|
+
])
|
|
663
|
+
},
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
assay(context, 'rejects relation loads beyond maxRelationDepth', async (driver) => {
|
|
667
|
+
const error = await captureFailure(() =>
|
|
668
|
+
driver.execute(
|
|
669
|
+
query({ relations: [nestedRelation(driver.capabilities.maxRelationDepth + 1)] }),
|
|
670
|
+
),
|
|
671
|
+
)
|
|
672
|
+
expect(error).toBeInstanceOf(Invalid)
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
assay(
|
|
676
|
+
context,
|
|
677
|
+
'enforces method-gated database capability surfaces in both directions',
|
|
678
|
+
async (driver) => {
|
|
679
|
+
assertCapabilitySurface(driver, 'transactions', driver.capabilities.transactions, [
|
|
680
|
+
'transaction',
|
|
681
|
+
])
|
|
682
|
+
assertCapabilitySurface(driver, 'rowSecurity', driver.capabilities.rowSecurity, [
|
|
683
|
+
'syncWards',
|
|
684
|
+
])
|
|
685
|
+
assertCapabilitySurface(driver, 'fullTextSearch', driver.capabilities.fullTextSearch, [
|
|
686
|
+
'search',
|
|
687
|
+
])
|
|
688
|
+
const unavailable = { capabilities: { transactions: false } as const }
|
|
689
|
+
assertCapabilitySurface(
|
|
690
|
+
unavailable,
|
|
691
|
+
'transactions',
|
|
692
|
+
unavailable.capabilities.transactions,
|
|
693
|
+
['transaction'],
|
|
694
|
+
)
|
|
695
|
+
expect(() =>
|
|
696
|
+
assertCapabilitySurface(
|
|
697
|
+
{ ...unavailable, transaction: async () => undefined },
|
|
698
|
+
'transactions',
|
|
699
|
+
false,
|
|
700
|
+
['transaction'],
|
|
701
|
+
),
|
|
702
|
+
).toThrow()
|
|
703
|
+
expect(() =>
|
|
704
|
+
assertCapabilitySurface(
|
|
705
|
+
{ capabilities: { transactions: true } as const },
|
|
706
|
+
'transactions',
|
|
707
|
+
true,
|
|
708
|
+
['transaction'],
|
|
709
|
+
),
|
|
710
|
+
).toThrow()
|
|
711
|
+
},
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
assay(
|
|
715
|
+
context,
|
|
716
|
+
'commits and rolls back transactions and propagates callback results',
|
|
717
|
+
async (driver) => {
|
|
718
|
+
if (!hasTransactions(driver)) return
|
|
719
|
+
|
|
720
|
+
await expect(
|
|
721
|
+
driver.transaction(async (transaction) => {
|
|
722
|
+
await transaction.execute(
|
|
723
|
+
query({
|
|
724
|
+
mode: 'insert',
|
|
725
|
+
select: [],
|
|
726
|
+
values: {
|
|
727
|
+
id: 'rolled-back',
|
|
728
|
+
email: 'rollback@example.test',
|
|
729
|
+
name: 'Rollback',
|
|
730
|
+
age: 10,
|
|
731
|
+
nickname: null,
|
|
732
|
+
},
|
|
733
|
+
}),
|
|
734
|
+
)
|
|
735
|
+
throw new Error('assay rollback')
|
|
736
|
+
}),
|
|
737
|
+
).rejects.toThrow('assay rollback')
|
|
738
|
+
expect(
|
|
739
|
+
await selectIds(driver, [
|
|
740
|
+
{ kind: 'compare', column: 'id', op: '=', value: 'rolled-back' },
|
|
741
|
+
]),
|
|
742
|
+
).toEqual([])
|
|
743
|
+
|
|
744
|
+
const callbackResult = { committed: true }
|
|
745
|
+
const result = await driver.transaction(async (transaction) => {
|
|
746
|
+
await transaction.execute(
|
|
747
|
+
query({
|
|
748
|
+
mode: 'insert',
|
|
749
|
+
select: [],
|
|
750
|
+
values: {
|
|
751
|
+
id: 'committed',
|
|
752
|
+
email: 'commit@example.test',
|
|
753
|
+
name: 'Commit',
|
|
754
|
+
age: 20,
|
|
755
|
+
nickname: null,
|
|
756
|
+
},
|
|
757
|
+
}),
|
|
758
|
+
)
|
|
759
|
+
return callbackResult
|
|
760
|
+
})
|
|
761
|
+
expect(result).toBe(callbackResult)
|
|
762
|
+
expect(
|
|
763
|
+
await selectIds(driver, [{ kind: 'compare', column: 'id', op: '=', value: 'committed' }]),
|
|
764
|
+
).toEqual(['committed'])
|
|
765
|
+
},
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
assay(context, 'normalizes unique insert and upsert violations to Conflict', async (driver) => {
|
|
769
|
+
await seed(driver, 'assay_users', [
|
|
770
|
+
{ id: 'u1', email: 'one@example.test', name: 'One', age: 10, nickname: null },
|
|
771
|
+
{ id: 'u2', email: 'two@example.test', name: 'Two', age: 20, nickname: null },
|
|
772
|
+
])
|
|
773
|
+
await expectConflict(() =>
|
|
774
|
+
driver.execute(
|
|
775
|
+
query({
|
|
776
|
+
mode: 'insert',
|
|
777
|
+
select: [],
|
|
778
|
+
values: { id: 'u3', email: 'one@example.test', name: 'Duplicate', age: 30 },
|
|
779
|
+
}),
|
|
780
|
+
),
|
|
781
|
+
)
|
|
782
|
+
if (driver.capabilities.upsert) {
|
|
783
|
+
await expectConflict(() =>
|
|
784
|
+
driver.execute(
|
|
785
|
+
query({
|
|
786
|
+
mode: 'upsert',
|
|
787
|
+
select: [],
|
|
788
|
+
values: { id: 'u1', email: 'two@example.test', name: 'Conflict', age: 10 },
|
|
789
|
+
conflict: { columns: ['id'], update: '*' },
|
|
790
|
+
}),
|
|
791
|
+
),
|
|
792
|
+
)
|
|
793
|
+
}
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
assay(context, 'rejects malformed upsert and count IR as Invalid', async (driver) => {
|
|
797
|
+
await expectInvalid(() =>
|
|
798
|
+
driver.execute(
|
|
799
|
+
query({
|
|
800
|
+
mode: 'upsert',
|
|
801
|
+
select: [],
|
|
802
|
+
values: { id: 'u1', email: 'one@example.test' },
|
|
803
|
+
}),
|
|
804
|
+
),
|
|
805
|
+
)
|
|
806
|
+
const malformedCounts: QueryIR[] = [
|
|
807
|
+
query({ mode: 'count', select: ['id'] }),
|
|
808
|
+
// Rejecting `*` costs count producers an explicit `[]`, but catches a leaked builder default.
|
|
809
|
+
query({ mode: 'count', select: '*' }),
|
|
810
|
+
query({ mode: 'count', select: [], relations: [relation()] }),
|
|
811
|
+
query({ mode: 'count', select: [], order: [{ column: 'id', direction: 'asc' }] }),
|
|
812
|
+
query({ mode: 'count', select: [], limit: 1 }),
|
|
813
|
+
query({ mode: 'count', select: [], offset: 1 }),
|
|
814
|
+
query({ mode: 'count', select: [], returning: ['id'] }),
|
|
815
|
+
]
|
|
816
|
+
for (const malformed of malformedCounts) {
|
|
817
|
+
await expectInvalid(() => driver.execute(malformed))
|
|
818
|
+
}
|
|
819
|
+
})
|
|
820
|
+
|
|
821
|
+
assay(context, 'rejects unknown identifiers as Invalid', async (driver) => {
|
|
822
|
+
const malformed: QueryIR[] = [
|
|
823
|
+
query({ table: 'unknown_table' }),
|
|
824
|
+
query({ select: ['unknown_column'] }),
|
|
825
|
+
query({ where: [{ kind: 'compare', column: 'unknown_column', op: '=', value: 1 }] }),
|
|
826
|
+
query({ order: [{ column: 'unknown_column', direction: 'asc' }] }),
|
|
827
|
+
query({ mode: 'insert', select: [], values: { unknown_column: 1 } }),
|
|
828
|
+
query({ mode: 'delete', select: [], returning: ['unknown_column'] }),
|
|
829
|
+
query({ relations: [relation({ table: 'unknown_table' })] }),
|
|
830
|
+
]
|
|
831
|
+
for (const entry of malformed) await expectInvalid(() => driver.execute(entry))
|
|
832
|
+
})
|
|
833
|
+
|
|
834
|
+
assay(context, 'round-trips RPC results from a provisioned routine', async (driver) => {
|
|
835
|
+
const args = { message: 'hello', nested: { count: 2 }, active: true }
|
|
836
|
+
expect(await driver.rpc<typeof args>('assay_echo', args)).toEqual(args)
|
|
837
|
+
})
|
|
838
|
+
|
|
839
|
+
assay(
|
|
840
|
+
context,
|
|
841
|
+
'maps a missing RPC routine to Invalid with routine metadata',
|
|
842
|
+
async (driver) => {
|
|
843
|
+
// NotFound is deliberately absent: DatabaseDriver has no must-exist operation. findOrFail and
|
|
844
|
+
// route-model binding own NotFound in the model layer. A missing routine is deployment drift,
|
|
845
|
+
// not a missing page; Invalid avoids a false 404 while preserving the routine name.
|
|
846
|
+
const error = await expectInvalid(() => driver.rpc('missing_assay_routine', {}))
|
|
847
|
+
expect(error.metadata.fields?.routine).toContain('missing_assay_routine')
|
|
848
|
+
},
|
|
849
|
+
)
|
|
850
|
+
})
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
type QueryIRCompareOp = Extract<Predicate, { kind: 'compare' }>['op']
|