@ultimabase/testing 0.0.0-stage → 0.1.1
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/CHANGELOG.md +19 -0
- package/README.md +4 -2
- package/dist/aggregates.d.ts +3 -0
- package/dist/aggregates.d.ts.map +1 -0
- package/dist/aggregates.js +99 -0
- package/dist/as-user.d.ts +13 -0
- package/dist/as-user.d.ts.map +1 -0
- package/dist/as-user.js +17 -0
- package/dist/bulk-returns.d.ts +3 -0
- package/dist/bulk-returns.d.ts.map +1 -0
- package/dist/bulk-returns.js +125 -0
- package/dist/cascades.d.ts +3 -0
- package/dist/cascades.d.ts.map +1 -0
- package/dist/cascades.js +269 -0
- package/dist/cases.d.ts +3 -0
- package/dist/cases.d.ts.map +1 -0
- package/dist/cases.js +60 -0
- package/dist/counts.d.ts +3 -0
- package/dist/counts.d.ts.map +1 -0
- package/dist/counts.js +158 -0
- package/dist/days.d.ts +3 -0
- package/dist/days.d.ts.map +1 -0
- package/dist/days.js +74 -0
- package/dist/decimals.d.ts +3 -0
- package/dist/decimals.d.ts.map +1 -0
- package/dist/decimals.js +70 -0
- package/dist/distincts.d.ts +3 -0
- package/dist/distincts.d.ts.map +1 -0
- package/dist/distincts.js +80 -0
- package/dist/feeds.d.ts +3 -0
- package/dist/feeds.d.ts.map +1 -0
- package/dist/feeds.js +164 -0
- package/dist/groups.d.ts +3 -0
- package/dist/groups.d.ts.map +1 -0
- package/dist/groups.js +149 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/json-paths.d.ts +3 -0
- package/dist/json-paths.d.ts.map +1 -0
- package/dist/json-paths.js +143 -0
- package/dist/lists.d.ts +3 -0
- package/dist/lists.d.ts.map +1 -0
- package/dist/lists.js +234 -0
- package/dist/nested.d.ts +3 -0
- package/dist/nested.d.ts.map +1 -0
- package/dist/nested.js +152 -0
- package/dist/numbers.d.ts +3 -0
- package/dist/numbers.d.ts.map +1 -0
- package/dist/numbers.js +92 -0
- package/dist/projection.d.ts +3 -0
- package/dist/projection.d.ts.map +1 -0
- package/dist/projection.js +53 -0
- package/dist/reads.d.ts +3 -0
- package/dist/reads.d.ts.map +1 -0
- package/dist/reads.js +312 -0
- package/dist/relation-filters.d.ts +3 -0
- package/dist/relation-filters.d.ts.map +1 -0
- package/dist/relation-filters.js +116 -0
- package/dist/round-trips.d.ts +3 -0
- package/dist/round-trips.d.ts.map +1 -0
- package/dist/round-trips.js +284 -0
- package/dist/searches.d.ts +3 -0
- package/dist/searches.d.ts.map +1 -0
- package/dist/searches.js +57 -0
- package/dist/silent.d.ts +3 -0
- package/dist/silent.d.ts.map +1 -0
- package/dist/silent.js +7 -0
- package/dist/sorts.d.ts +3 -0
- package/dist/sorts.d.ts.map +1 -0
- package/dist/sorts.js +108 -0
- package/dist/suite.d.ts +7 -0
- package/dist/suite.d.ts.map +1 -0
- package/dist/suite.js +15 -0
- package/dist/test-adapter.d.ts +11 -0
- package/dist/test-adapter.d.ts.map +1 -0
- package/dist/test-adapter.js +55 -0
- package/dist/test-application.d.ts +17 -0
- package/dist/test-application.d.ts.map +1 -0
- package/dist/test-application.js +68 -0
- package/dist/transactions.d.ts +3 -0
- package/dist/transactions.d.ts.map +1 -0
- package/dist/transactions.js +61 -0
- package/dist/virtuals.d.ts +3 -0
- package/dist/virtuals.d.ts.map +1 -0
- package/dist/virtuals.js +135 -0
- package/dist/writes.d.ts +3 -0
- package/dist/writes.d.ts.map +1 -0
- package/dist/writes.js +219 -0
- package/package.json +40 -3
- package/src/aggregates.ts +128 -0
- package/src/as-user.ts +26 -0
- package/src/bulk-returns.ts +151 -0
- package/src/cascades.ts +338 -0
- package/src/cases.ts +76 -0
- package/src/counts.ts +223 -0
- package/src/days.ts +107 -0
- package/src/decimals.ts +102 -0
- package/src/distincts.ts +107 -0
- package/src/feeds.ts +197 -0
- package/src/groups.ts +185 -0
- package/src/index.ts +6 -0
- package/src/json-paths.ts +185 -0
- package/src/lists.ts +304 -0
- package/src/nested.ts +187 -0
- package/src/numbers.ts +125 -0
- package/src/projection.ts +65 -0
- package/src/reads.ts +389 -0
- package/src/relation-filters.ts +151 -0
- package/src/round-trips.ts +392 -0
- package/src/searches.ts +82 -0
- package/src/silent.ts +11 -0
- package/src/sorts.ts +143 -0
- package/src/suite.ts +21 -0
- package/src/test-adapter.ts +68 -0
- package/src/test-application.ts +93 -0
- package/src/transactions.ts +76 -0
- package/src/virtuals.ts +184 -0
- package/src/writes.ts +315 -0
package/src/lists.ts
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// The lists of the contract (D-120, D-127, D-149): of strings, numbers or
|
|
2
|
+
// enum values, `[]` when unwritten, replaced whole or updated with `push`,
|
|
3
|
+
// `pushUnique` or `remove`, filtered with `has`, `hasSome`, `hasEvery` and
|
|
4
|
+
// `isEmpty`, indexed by GIN in PostgreSQL.
|
|
5
|
+
import type { Adapter, UpdateDataOf, WhereOf } from 'ultimabase'
|
|
6
|
+
import { defineModel, master } from 'ultimabase'
|
|
7
|
+
import { describe, expect, it } from 'vitest'
|
|
8
|
+
import { started } from './suite.ts'
|
|
9
|
+
|
|
10
|
+
const M = { as: master }
|
|
11
|
+
|
|
12
|
+
const Article = defineModel({
|
|
13
|
+
name: 'Article',
|
|
14
|
+
fields: (f) => ({
|
|
15
|
+
title: f.string({ unique: true }),
|
|
16
|
+
tags: f.string({ list: true }),
|
|
17
|
+
labels: f.string({ list: true, default: ['new'] }),
|
|
18
|
+
scores: f.number({ list: true }),
|
|
19
|
+
sizes: f.enumOf(['S', 'M', 'L'], { list: true, default: ['M'] }),
|
|
20
|
+
}),
|
|
21
|
+
indexes: [
|
|
22
|
+
{ fields: ['tags'] },
|
|
23
|
+
{ fields: ['scores'] },
|
|
24
|
+
{ fields: ['sizes'] },
|
|
25
|
+
],
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
/** An update hook: `updateMany` locks the rows and groups their writes. */
|
|
29
|
+
const Shelf = defineModel({
|
|
30
|
+
name: 'Shelf',
|
|
31
|
+
fields: (f) => ({ tags: f.string({ list: true }) }),
|
|
32
|
+
}).with({ hooks: { beforeUpdate: () => {} } })
|
|
33
|
+
|
|
34
|
+
const titles = async (where: WhereOf<typeof Article>): Promise<string[]> =>
|
|
35
|
+
(await Article.findMany({ where }, M)).map((article) => article.title)
|
|
36
|
+
|
|
37
|
+
export function lists(create: () => Adapter): void {
|
|
38
|
+
describe('lists', () => {
|
|
39
|
+
started(create, [Article, Shelf])
|
|
40
|
+
|
|
41
|
+
it('stores a list, [] when unwritten, and replaces it whole', async () => {
|
|
42
|
+
const ts = await Article.create(
|
|
43
|
+
{ data: { title: 'ts', tags: ['ts', 'node'] } },
|
|
44
|
+
M,
|
|
45
|
+
)
|
|
46
|
+
expect(ts).toMatchObject({ tags: ['ts', 'node'], labels: ['new'] })
|
|
47
|
+
expect(
|
|
48
|
+
await Article.create({ data: { title: 'none', labels: [] } }, M),
|
|
49
|
+
).toMatchObject({ tags: [], labels: [] })
|
|
50
|
+
await Article.update(
|
|
51
|
+
{ where: { id: ts.id }, data: { tags: ['go', 'go'] } },
|
|
52
|
+
M,
|
|
53
|
+
)
|
|
54
|
+
expect(
|
|
55
|
+
await Article.findUnique({ where: { title: 'ts' } }, M),
|
|
56
|
+
).toMatchObject({ tags: ['go', 'go'], labels: ['new'] })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('filters a list with has, hasSome and NOT', async () => {
|
|
60
|
+
for (const [title, tags] of [
|
|
61
|
+
['a', ['ts', 'node']],
|
|
62
|
+
['b', ['go']],
|
|
63
|
+
['c', []],
|
|
64
|
+
] as const) {
|
|
65
|
+
await Article.create({ data: { title, tags } }, M)
|
|
66
|
+
}
|
|
67
|
+
expect(await titles({ tags: { has: 'ts' } })).toEqual(['a'])
|
|
68
|
+
expect(await titles({ tags: { has: 'rust' } })).toEqual([])
|
|
69
|
+
expect(await titles({ tags: { hasSome: ['go', 'node'] } })).toEqual([
|
|
70
|
+
'a',
|
|
71
|
+
'b',
|
|
72
|
+
])
|
|
73
|
+
expect(await titles({ tags: { hasSome: [] } })).toEqual([])
|
|
74
|
+
expect(await titles({ NOT: { tags: { has: 'ts' } } })).toEqual(['b', 'c'])
|
|
75
|
+
expect(
|
|
76
|
+
await titles({ tags: { has: 'node', hasSome: ['go', 'ts'] } }),
|
|
77
|
+
).toEqual(['a'])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('filters a list with hasEvery and isEmpty', async () => {
|
|
81
|
+
for (const [title, tags, scores, sizes] of [
|
|
82
|
+
['a', ['ts', 'node', 'ts'], [1, 2], ['S', 'L']],
|
|
83
|
+
['b', ['ts'], [2], []],
|
|
84
|
+
['c', [], [], ['L']],
|
|
85
|
+
] as const) {
|
|
86
|
+
await Article.create({ data: { title, tags, scores, sizes } }, M)
|
|
87
|
+
}
|
|
88
|
+
expect(await titles({ tags: { hasEvery: ['node', 'ts'] } })).toEqual([
|
|
89
|
+
'a',
|
|
90
|
+
])
|
|
91
|
+
expect(await titles({ tags: { hasEvery: ['ts', 'ts'] } })).toEqual([
|
|
92
|
+
'a',
|
|
93
|
+
'b',
|
|
94
|
+
])
|
|
95
|
+
expect(await titles({ tags: { hasEvery: [] } })).toEqual(['a', 'b', 'c'])
|
|
96
|
+
expect(await titles({ scores: { hasEvery: [2] } })).toEqual(['a', 'b'])
|
|
97
|
+
expect(await titles({ sizes: { hasEvery: ['L', 'S'] } })).toEqual(['a'])
|
|
98
|
+
expect(await titles({ tags: { isEmpty: true } })).toEqual(['c'])
|
|
99
|
+
expect(await titles({ sizes: { isEmpty: false } })).toEqual(['a', 'c'])
|
|
100
|
+
expect(await titles({ NOT: { scores: { isEmpty: true } } })).toEqual([
|
|
101
|
+
'a',
|
|
102
|
+
'b',
|
|
103
|
+
])
|
|
104
|
+
expect(
|
|
105
|
+
await titles({
|
|
106
|
+
tags: { hasEvery: ['ts'], isEmpty: false, has: 'node' },
|
|
107
|
+
}),
|
|
108
|
+
).toEqual(['a'])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('updates a list with set, push, pushUnique and remove', async () => {
|
|
112
|
+
const { id } = await Article.create(
|
|
113
|
+
{ data: { title: 'a', tags: ['a', 'a'], scores: [1, 2, 1] } },
|
|
114
|
+
M,
|
|
115
|
+
)
|
|
116
|
+
const updated = (data: UpdateDataOf<typeof Article>) =>
|
|
117
|
+
Article.update({ where: { id }, data }, M)
|
|
118
|
+
const tags = async (data: UpdateDataOf<typeof Article>['tags']) =>
|
|
119
|
+
(await updated({ tags: data })).tags
|
|
120
|
+
expect(await tags({ pushUnique: 'b' })).toEqual(['a', 'a', 'b'])
|
|
121
|
+
expect(await tags({ pushUnique: ['a', 'c', 'c', 'd'] })).toEqual([
|
|
122
|
+
'a',
|
|
123
|
+
'a',
|
|
124
|
+
'b',
|
|
125
|
+
'c',
|
|
126
|
+
'd',
|
|
127
|
+
])
|
|
128
|
+
expect(await tags({ push: ['b', 'b'] })).toEqual([
|
|
129
|
+
'a',
|
|
130
|
+
'a',
|
|
131
|
+
'b',
|
|
132
|
+
'c',
|
|
133
|
+
'd',
|
|
134
|
+
'b',
|
|
135
|
+
'b',
|
|
136
|
+
])
|
|
137
|
+
expect(await tags({ remove: ['a', 'z'] })).toEqual([
|
|
138
|
+
'b',
|
|
139
|
+
'c',
|
|
140
|
+
'd',
|
|
141
|
+
'b',
|
|
142
|
+
'b',
|
|
143
|
+
])
|
|
144
|
+
expect(await tags({ remove: 'b' })).toEqual(['c', 'd'])
|
|
145
|
+
expect(await tags({ push: ['"', 'NULL', '{}'] })).toEqual([
|
|
146
|
+
'c',
|
|
147
|
+
'd',
|
|
148
|
+
'"',
|
|
149
|
+
'NULL',
|
|
150
|
+
'{}',
|
|
151
|
+
])
|
|
152
|
+
expect(await tags({ remove: ['NULL', 'c'] })).toEqual(['d', '"', '{}'])
|
|
153
|
+
expect(await tags({ push: [] })).toEqual(['d', '"', '{}'])
|
|
154
|
+
expect(await tags({ push: undefined })).toEqual(['d', '"', '{}'])
|
|
155
|
+
expect(await tags({ set: 'x' })).toEqual(['x'])
|
|
156
|
+
expect(
|
|
157
|
+
await updated({
|
|
158
|
+
scores: { remove: 1 },
|
|
159
|
+
sizes: { pushUnique: ['L', 'M', 'L'] },
|
|
160
|
+
labels: { set: [] },
|
|
161
|
+
}),
|
|
162
|
+
).toMatchObject({ scores: [2], sizes: ['M', 'L'], labels: [] })
|
|
163
|
+
const upserted = await Article.upsert(
|
|
164
|
+
{
|
|
165
|
+
where: { id },
|
|
166
|
+
create: { title: 'b' },
|
|
167
|
+
update: { tags: { pushUnique: ['x', 'y'] } },
|
|
168
|
+
},
|
|
169
|
+
M,
|
|
170
|
+
)
|
|
171
|
+
expect(upserted.tags).toEqual(['x', 'y'])
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('updates the lists of many rows, with or without their hooks', async () => {
|
|
175
|
+
for (const [title, tags] of [
|
|
176
|
+
['a', ['x', 'y', 'x']],
|
|
177
|
+
['b', []],
|
|
178
|
+
] as const) {
|
|
179
|
+
await Article.create({ data: { title, tags } }, M)
|
|
180
|
+
await Shelf.create({ data: { tags } }, M)
|
|
181
|
+
}
|
|
182
|
+
for (const model of [Article, Shelf]) {
|
|
183
|
+
expect(
|
|
184
|
+
await model.updateMany({ data: { tags: { remove: 'x' } } }, M),
|
|
185
|
+
).toEqual({ count: 2 })
|
|
186
|
+
expect(
|
|
187
|
+
await model.updateMany({ data: { tags: { pushUnique: 'z' } } }, M),
|
|
188
|
+
).toEqual({ count: 2 })
|
|
189
|
+
}
|
|
190
|
+
expect((await Article.findMany({}, M)).map((row) => row.tags)).toEqual([
|
|
191
|
+
['y', 'z'],
|
|
192
|
+
['z'],
|
|
193
|
+
])
|
|
194
|
+
expect((await Shelf.findMany({}, M)).map((row) => row.tags)).toEqual([
|
|
195
|
+
['y', 'z'],
|
|
196
|
+
['z'],
|
|
197
|
+
])
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('refuses a list operation that is not one, or a bad element', async () => {
|
|
201
|
+
const { id } = await Article.create(
|
|
202
|
+
{ data: { title: 'a', tags: ['a'] } },
|
|
203
|
+
M,
|
|
204
|
+
)
|
|
205
|
+
const takes =
|
|
206
|
+
'data.tags takes a list or one of set, push, pushUnique, remove.'
|
|
207
|
+
const refusals: [unknown, string, string][] = [
|
|
208
|
+
[{}, 'INVALID_QUERY', takes],
|
|
209
|
+
[{ push: 'b', remove: 'a' }, 'INVALID_QUERY', takes],
|
|
210
|
+
[{ append: 'b' }, 'INVALID_QUERY', takes],
|
|
211
|
+
[{ push: '' }, 'VALIDATION_FAILED', 'Invalid data.'],
|
|
212
|
+
[{ pushUnique: ['b', '\0'] }, 'VALIDATION_FAILED', 'Invalid data.'],
|
|
213
|
+
]
|
|
214
|
+
for (const [tags, code, message] of refusals) {
|
|
215
|
+
await expect(
|
|
216
|
+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the test sends what the types refuse.
|
|
217
|
+
Article.update({ where: { id }, data: { tags } as { tags: [] } }, M),
|
|
218
|
+
).rejects.toMatchObject({ code, message })
|
|
219
|
+
}
|
|
220
|
+
await expect(
|
|
221
|
+
Article.update({ where: { id }, data: { tags: { push: '' } } }, M),
|
|
222
|
+
).rejects.toMatchObject({
|
|
223
|
+
details: {
|
|
224
|
+
issues: [
|
|
225
|
+
{
|
|
226
|
+
path: ['tags'],
|
|
227
|
+
message:
|
|
228
|
+
'Expected a list of non-empty strings without a NUL or a lone surrogate.',
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
},
|
|
232
|
+
})
|
|
233
|
+
const pushed: unknown = { push: 'a' }
|
|
234
|
+
const data = { title: 'b', tags: pushed } as { title: string }
|
|
235
|
+
await expect(Article.create({ data }, M)).rejects.toMatchObject({
|
|
236
|
+
code: 'VALIDATION_FAILED',
|
|
237
|
+
})
|
|
238
|
+
expect(await Article.findUnique({ where: { id } }, M)).toMatchObject({
|
|
239
|
+
tags: ['a'],
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('stores and filters numbers and enum values', async () => {
|
|
244
|
+
const a = await Article.create(
|
|
245
|
+
{ data: { title: 'a', scores: [1.5, -2, 0, 1.5], sizes: ['S', 'L'] } },
|
|
246
|
+
M,
|
|
247
|
+
)
|
|
248
|
+
expect(a).toMatchObject({ scores: [1.5, -2, 0, 1.5], sizes: ['S', 'L'] })
|
|
249
|
+
expect(
|
|
250
|
+
await Article.create({ data: { title: 'b', scores: [1e300] } }, M),
|
|
251
|
+
).toMatchObject({ scores: [1e300], sizes: ['M'] })
|
|
252
|
+
await Article.create({ data: { title: 'c', sizes: [] } }, M)
|
|
253
|
+
expect(await titles({ scores: { has: -2 } })).toEqual(['a'])
|
|
254
|
+
expect(await titles({ scores: { has: 2 } })).toEqual([])
|
|
255
|
+
expect(await titles({ scores: { hasSome: [0, 1e300] } })).toEqual([
|
|
256
|
+
'a',
|
|
257
|
+
'b',
|
|
258
|
+
])
|
|
259
|
+
expect(await titles({ sizes: { has: 'M' } })).toEqual(['b'])
|
|
260
|
+
expect(await titles({ sizes: { hasSome: ['S', 'M'] } })).toEqual([
|
|
261
|
+
'a',
|
|
262
|
+
'b',
|
|
263
|
+
])
|
|
264
|
+
expect(await titles({ NOT: { sizes: { has: 'S' } } })).toEqual(['b', 'c'])
|
|
265
|
+
await Article.update(
|
|
266
|
+
{ where: { id: a.id }, data: { scores: [], sizes: ['L', 'L'] } },
|
|
267
|
+
M,
|
|
268
|
+
)
|
|
269
|
+
expect(
|
|
270
|
+
await Article.findUnique({ where: { title: 'a' } }, M),
|
|
271
|
+
).toMatchObject({ scores: [], sizes: ['L', 'L'] })
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('stores any well-formed text as an element', async () => {
|
|
275
|
+
const tags = ['"', '\\', 'a,b', '{}', ' ', 'NULL', 'é😀']
|
|
276
|
+
await Article.create({ data: { title: 'odd', tags } }, M)
|
|
277
|
+
expect(
|
|
278
|
+
await Article.findUnique({ where: { title: 'odd' } }, M),
|
|
279
|
+
).toMatchObject({ tags })
|
|
280
|
+
for (const tag of tags) {
|
|
281
|
+
expect(await titles({ tags: { has: tag } })).toEqual(['odd'])
|
|
282
|
+
}
|
|
283
|
+
expect(await titles({ tags: { hasSome: ['null'] } })).toEqual([])
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('refuses an empty element, a NUL or a lone surrogate', async () => {
|
|
287
|
+
for (const tags of [[''], ['a\0'], ['\uD800']]) {
|
|
288
|
+
await expect(
|
|
289
|
+
Article.create({ data: { title: 'bad', tags } }, M),
|
|
290
|
+
).rejects.toMatchObject({ code: 'VALIDATION_FAILED' })
|
|
291
|
+
}
|
|
292
|
+
for (const where of [
|
|
293
|
+
{ tags: { has: '' } },
|
|
294
|
+
{ tags: { hasSome: ['\0'] } },
|
|
295
|
+
{ tags: { hasEvery: [''] } },
|
|
296
|
+
]) {
|
|
297
|
+
await expect(Article.findMany({ where }, M)).rejects.toMatchObject({
|
|
298
|
+
code: 'INVALID_QUERY',
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
expect(await Article.count({}, M)).toBe(0)
|
|
302
|
+
})
|
|
303
|
+
})
|
|
304
|
+
}
|
package/src/nested.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Nested creation in the contract (D-128): `create` in a relation write makes
|
|
2
|
+
// the rows with the one it writes, at any depth, or none of them.
|
|
3
|
+
import type { Adapter } from 'ultimabase'
|
|
4
|
+
import { defineModel, master } from 'ultimabase'
|
|
5
|
+
import { describe, expect, it } from 'vitest'
|
|
6
|
+
import { started } from './suite.ts'
|
|
7
|
+
|
|
8
|
+
const M = { as: master }
|
|
9
|
+
|
|
10
|
+
const Blogger = defineModel({
|
|
11
|
+
name: 'Blogger',
|
|
12
|
+
fields: (f) => ({
|
|
13
|
+
name: f.string(),
|
|
14
|
+
posts: f.many(() => Post),
|
|
15
|
+
card: f.one(() => Card, { optional: true }),
|
|
16
|
+
}),
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
/** Holds the key of its one-to-one with a blogger (D-37). */
|
|
20
|
+
const Card = defineModel({
|
|
21
|
+
name: 'Card',
|
|
22
|
+
fields: (f) => ({
|
|
23
|
+
bio: f.string(),
|
|
24
|
+
blogger: f.one(() => Blogger, { onDelete: 'cascade' }),
|
|
25
|
+
}),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const Post = defineModel({
|
|
29
|
+
name: 'Post',
|
|
30
|
+
fields: (f) => ({
|
|
31
|
+
title: f.string({ unique: true }),
|
|
32
|
+
blogger: f.one(() => Blogger, { onDelete: 'cascade' }),
|
|
33
|
+
comments: f.many(() => Comment),
|
|
34
|
+
topics: f.many(() => Topic),
|
|
35
|
+
}),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const Comment = defineModel({
|
|
39
|
+
name: 'Comment',
|
|
40
|
+
fields: (f) => ({
|
|
41
|
+
body: f.string(),
|
|
42
|
+
post: f.one(() => Post, { onDelete: 'cascade' }),
|
|
43
|
+
}),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const Topic = defineModel({
|
|
47
|
+
name: 'Topic',
|
|
48
|
+
fields: (f) => ({ label: f.string(), posts: f.many(() => Post) }),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
export function nested(create: () => Adapter): void {
|
|
52
|
+
describe('nested creation', () => {
|
|
53
|
+
started(create, [Blogger, Card, Post, Comment, Topic])
|
|
54
|
+
|
|
55
|
+
it('creates rows three levels deep with the one it writes', async () => {
|
|
56
|
+
const topic = await Topic.create({ data: { label: 'ts' } }, M)
|
|
57
|
+
const blogger = await Blogger.create(
|
|
58
|
+
{
|
|
59
|
+
data: {
|
|
60
|
+
name: 'Ada',
|
|
61
|
+
card: { create: { bio: 'engines' } },
|
|
62
|
+
posts: {
|
|
63
|
+
create: [
|
|
64
|
+
{
|
|
65
|
+
title: 'First',
|
|
66
|
+
comments: { create: [{ body: 'a' }, { body: 'b' }] },
|
|
67
|
+
topics: {
|
|
68
|
+
connect: [{ id: topic.id }],
|
|
69
|
+
create: [{ label: 'go' }],
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{ title: 'Second' },
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
include: {
|
|
77
|
+
card: true,
|
|
78
|
+
posts: { include: { comments: true, topics: true } },
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
M,
|
|
82
|
+
)
|
|
83
|
+
expect(blogger).toMatchObject({
|
|
84
|
+
name: 'Ada',
|
|
85
|
+
card: { bio: 'engines' },
|
|
86
|
+
posts: [
|
|
87
|
+
{
|
|
88
|
+
title: 'First',
|
|
89
|
+
comments: [{ body: 'a' }, { body: 'b' }],
|
|
90
|
+
topics: [{ label: 'ts' }, { label: 'go' }],
|
|
91
|
+
},
|
|
92
|
+
{ title: 'Second', comments: [], topics: [] },
|
|
93
|
+
],
|
|
94
|
+
})
|
|
95
|
+
expect(await Comment.count({}, M)).toBe(2)
|
|
96
|
+
expect(await Topic.count({}, M)).toBe(2)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('creates the row a stored one links to', async () => {
|
|
100
|
+
const comment = await Comment.create(
|
|
101
|
+
{
|
|
102
|
+
data: {
|
|
103
|
+
body: 'hello',
|
|
104
|
+
post: {
|
|
105
|
+
create: { title: 'Root', blogger: { create: { name: 'Bo' } } },
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
include: { post: { include: { blogger: true } } },
|
|
109
|
+
},
|
|
110
|
+
M,
|
|
111
|
+
)
|
|
112
|
+
expect(comment).toMatchObject({
|
|
113
|
+
post: { title: 'Root', blogger: { name: 'Bo' } },
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('creates rows in an update, beside connect and disconnect', async () => {
|
|
118
|
+
const blogger = await Blogger.create({ data: { name: 'Ada' } }, M)
|
|
119
|
+
const old = await Topic.create({ data: { label: 'old' } }, M)
|
|
120
|
+
const post = await Post.create(
|
|
121
|
+
{
|
|
122
|
+
data: {
|
|
123
|
+
title: 'Kept',
|
|
124
|
+
blogger: { connect: { id: blogger.id } },
|
|
125
|
+
topics: { connect: [{ id: old.id }] },
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
M,
|
|
129
|
+
)
|
|
130
|
+
const updated = await Post.update(
|
|
131
|
+
{
|
|
132
|
+
where: { id: post.id },
|
|
133
|
+
data: {
|
|
134
|
+
comments: { create: [{ body: 'late' }] },
|
|
135
|
+
topics: {
|
|
136
|
+
disconnect: [{ id: old.id }],
|
|
137
|
+
create: [{ label: 'new' }],
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
include: { comments: true, topics: true },
|
|
141
|
+
},
|
|
142
|
+
M,
|
|
143
|
+
)
|
|
144
|
+
expect(updated).toMatchObject({
|
|
145
|
+
comments: [{ body: 'late' }],
|
|
146
|
+
topics: [{ label: 'new' }],
|
|
147
|
+
})
|
|
148
|
+
const card = await Blogger.update(
|
|
149
|
+
{
|
|
150
|
+
where: { id: blogger.id },
|
|
151
|
+
data: { card: { create: { bio: 'later' } } },
|
|
152
|
+
include: { card: true },
|
|
153
|
+
},
|
|
154
|
+
M,
|
|
155
|
+
)
|
|
156
|
+
expect(card).toMatchObject({ card: { bio: 'later' } })
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('writes nothing when a row deep down fails', async () => {
|
|
160
|
+
const blogger = await Blogger.create({ data: { name: 'Ada' } }, M)
|
|
161
|
+
await Post.create(
|
|
162
|
+
{
|
|
163
|
+
data: { title: 'Taken', blogger: { connect: { id: blogger.id } } },
|
|
164
|
+
},
|
|
165
|
+
M,
|
|
166
|
+
)
|
|
167
|
+
const failed = Blogger.create(
|
|
168
|
+
{
|
|
169
|
+
data: {
|
|
170
|
+
name: 'Bo',
|
|
171
|
+
posts: {
|
|
172
|
+
create: [
|
|
173
|
+
{ title: 'Fresh', comments: { create: [{ body: 'x' }] } },
|
|
174
|
+
{ title: 'Taken' },
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
M,
|
|
180
|
+
)
|
|
181
|
+
await expect(failed).rejects.toMatchObject({ code: 'UNIQUE_VIOLATION' })
|
|
182
|
+
expect(await Blogger.count({}, M)).toBe(1)
|
|
183
|
+
expect(await Post.count({}, M)).toBe(1)
|
|
184
|
+
expect(await Comment.count({}, M)).toBe(0)
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
}
|
package/src/numbers.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// The numbers of the contract (D-132): integer fields in 32 bits, and number
|
|
2
|
+
// updates computed by the write on the row as it finds it, `null` as 0.
|
|
3
|
+
import type { Adapter, UpdateDataOf } from 'ultimabase'
|
|
4
|
+
import { defineModel, master } from 'ultimabase'
|
|
5
|
+
import { describe, expect, it } from 'vitest'
|
|
6
|
+
import { started } from './suite.ts'
|
|
7
|
+
|
|
8
|
+
const M = { as: master }
|
|
9
|
+
|
|
10
|
+
const Tally = defineModel({
|
|
11
|
+
name: 'Tally',
|
|
12
|
+
fields: (f) => ({
|
|
13
|
+
count: f.number({ integer: true, optional: true }),
|
|
14
|
+
ratio: f.number({ optional: true }),
|
|
15
|
+
points: f.number({ integer: true, list: true }),
|
|
16
|
+
}),
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
/** An update hook: `updateMany` locks the rows and groups their writes. */
|
|
20
|
+
const Stock = defineModel({
|
|
21
|
+
name: 'Stock',
|
|
22
|
+
fields: (f) => ({ level: f.number({ integer: true }) }),
|
|
23
|
+
}).with({ hooks: { beforeUpdate: () => {} } })
|
|
24
|
+
|
|
25
|
+
const whole = 'Expected a whole number from -2,147,483,648 to 2,147,483,647.'
|
|
26
|
+
|
|
27
|
+
const update = async (
|
|
28
|
+
data: UpdateDataOf<typeof Tally>,
|
|
29
|
+
from: { count?: number | null; ratio?: number | null },
|
|
30
|
+
) => {
|
|
31
|
+
const { id } = await Tally.create({ data: from }, M)
|
|
32
|
+
return Tally.update({ where: { id }, data }, M)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function numbers(create: () => Adapter): void {
|
|
36
|
+
describe('numbers', () => {
|
|
37
|
+
started(create, [Tally, Stock])
|
|
38
|
+
|
|
39
|
+
it('stores and filters whole numbers in 32 bits', async () => {
|
|
40
|
+
const points = [-2_147_483_648, 0, 2_147_483_647]
|
|
41
|
+
await Tally.create({ data: { count: 2_147_483_647, points } }, M)
|
|
42
|
+
await Tally.create({ data: { count: -3, points: [1] } }, M)
|
|
43
|
+
const found = await Tally.findMany(
|
|
44
|
+
{ where: { count: { gt: 0 }, points: { has: 0 } } },
|
|
45
|
+
M,
|
|
46
|
+
)
|
|
47
|
+
expect(found).toMatchObject([{ count: 2_147_483_647, points }])
|
|
48
|
+
expect(
|
|
49
|
+
await Tally.count({ where: { points: { hasSome: [1, 5] } } }, M),
|
|
50
|
+
).toBe(1)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('computes each operation on the stored number', async () => {
|
|
54
|
+
expect(
|
|
55
|
+
await update({ count: { increment: 3 } }, { count: 4 }),
|
|
56
|
+
).toMatchObject({ count: 7 })
|
|
57
|
+
expect(
|
|
58
|
+
await update({ count: { decrement: 2 } }, { count: null }),
|
|
59
|
+
).toMatchObject({ count: -2 })
|
|
60
|
+
expect(
|
|
61
|
+
await update({ count: { divide: 2 } }, { count: -7 }),
|
|
62
|
+
).toMatchObject({ count: -3 })
|
|
63
|
+
expect(
|
|
64
|
+
await update({ ratio: { multiply: 1.5 } }, { ratio: 3 }),
|
|
65
|
+
).toMatchObject({ ratio: 4.5 })
|
|
66
|
+
expect(
|
|
67
|
+
await update({ ratio: { divide: 4 } }, { ratio: 3 }),
|
|
68
|
+
).toMatchObject({ ratio: 0.75 })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('computes updateMany on each row, with or without its hooks', async () => {
|
|
72
|
+
for (const count of [1, null, 5]) {
|
|
73
|
+
await Tally.create({ data: { count } }, M)
|
|
74
|
+
}
|
|
75
|
+
const data = { count: { increment: 2 } }
|
|
76
|
+
expect(await Tally.updateMany({ data }, M)).toEqual({ count: 3 })
|
|
77
|
+
const counts = async () =>
|
|
78
|
+
(await Tally.findMany({}, M)).map((row) => row.count)
|
|
79
|
+
expect(await counts()).toEqual([3, 2, 7])
|
|
80
|
+
await Tally.create({ data: { count: 2_147_483_647 } }, M)
|
|
81
|
+
await expect(Tally.updateMany({ data }, M)).rejects.toMatchObject({
|
|
82
|
+
code: 'VALIDATION_FAILED',
|
|
83
|
+
details: { issues: [{ path: ['count'], message: whole }] },
|
|
84
|
+
})
|
|
85
|
+
expect(await counts()).toEqual([3, 2, 7, 2_147_483_647])
|
|
86
|
+
for (const level of [1, 2]) await Stock.create({ data: { level } }, M)
|
|
87
|
+
const doubled = { level: { multiply: 2 } }
|
|
88
|
+
expect(await Stock.updateMany({ data: doubled }, M)).toEqual({
|
|
89
|
+
count: 2,
|
|
90
|
+
})
|
|
91
|
+
const levels = await Stock.findMany({}, M)
|
|
92
|
+
expect(levels.map((row) => row.level)).toEqual([2, 4])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('refuses a result out of bounds, and keeps the row', async () => {
|
|
96
|
+
for (const [data, from, path, message] of [
|
|
97
|
+
[{ count: { increment: 1 } }, { count: 2_147_483_647 }, 'count', whole],
|
|
98
|
+
[
|
|
99
|
+
{ count: { multiply: -2 } },
|
|
100
|
+
{ count: -1_073_741_825 },
|
|
101
|
+
'count',
|
|
102
|
+
whole,
|
|
103
|
+
],
|
|
104
|
+
[
|
|
105
|
+
{ ratio: { multiply: 10 } },
|
|
106
|
+
{ ratio: 1e308 },
|
|
107
|
+
'ratio',
|
|
108
|
+
'Expected a finite number.',
|
|
109
|
+
],
|
|
110
|
+
] as const) {
|
|
111
|
+
await expect(update(data, from)).rejects.toMatchObject({
|
|
112
|
+
code: 'VALIDATION_FAILED',
|
|
113
|
+
details: { issues: [{ path: [path], message }] },
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
expect(
|
|
117
|
+
(await Tally.findMany({}, M)).map(({ count, ratio }) => [count, ratio]),
|
|
118
|
+
).toEqual([
|
|
119
|
+
[2_147_483_647, null],
|
|
120
|
+
[-1_073_741_825, null],
|
|
121
|
+
[null, 1e308],
|
|
122
|
+
])
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// A read's `fields` (D-136): the rows hold `id` and those fields only, in a
|
|
2
|
+
// relation too; without them, the whole row and each `one` key.
|
|
3
|
+
import type { Adapter } from 'ultimabase'
|
|
4
|
+
import { defineModel, master } from 'ultimabase'
|
|
5
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
6
|
+
import { started } from './suite.ts'
|
|
7
|
+
|
|
8
|
+
const M = { as: master }
|
|
9
|
+
|
|
10
|
+
const Writer = defineModel({
|
|
11
|
+
name: 'Writer',
|
|
12
|
+
fields: (f) => ({ name: f.string(), notes: f.many(() => Note) }),
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const Note = defineModel({
|
|
16
|
+
name: 'Note',
|
|
17
|
+
fields: (f) => ({
|
|
18
|
+
title: f.string(),
|
|
19
|
+
body: f.string(),
|
|
20
|
+
writer: f.one(() => Writer),
|
|
21
|
+
}),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
export function projection(create: () => Adapter): void {
|
|
25
|
+
describe('projection', () => {
|
|
26
|
+
const database = started(create, [Writer, Note])
|
|
27
|
+
|
|
28
|
+
let ada: string
|
|
29
|
+
beforeEach(async () => {
|
|
30
|
+
ada = (await Writer.create({ data: { name: 'Ada' } }, M)).id
|
|
31
|
+
await Note.create(
|
|
32
|
+
{
|
|
33
|
+
data: {
|
|
34
|
+
title: 'one',
|
|
35
|
+
body: 'long',
|
|
36
|
+
writer: { connect: { id: ada } },
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
M,
|
|
40
|
+
)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('reads the fields a read names, in a relation too', async () => {
|
|
44
|
+
const [note] = await database().find({
|
|
45
|
+
model: 'Note',
|
|
46
|
+
fields: ['title'],
|
|
47
|
+
include: { writer: { fields: ['name'] } },
|
|
48
|
+
})
|
|
49
|
+
expect(note).toEqual({
|
|
50
|
+
id: note?.id,
|
|
51
|
+
title: 'one',
|
|
52
|
+
writer: { id: ada, name: 'Ada' },
|
|
53
|
+
})
|
|
54
|
+
const [whole] = await database().find({ model: 'Note' })
|
|
55
|
+
expect(Object.keys(whole ?? {}).toSorted()).toEqual([
|
|
56
|
+
'body',
|
|
57
|
+
'createdAt',
|
|
58
|
+
'id',
|
|
59
|
+
'title',
|
|
60
|
+
'updatedAt',
|
|
61
|
+
'writer',
|
|
62
|
+
])
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
}
|