@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
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// The round trips of the contract, BEGIN and COMMIT included (D-46, D-48,
|
|
2
|
+
// D-99): a read, its relations at any depth, counts and sorts through
|
|
3
|
+
// relations included (D-134), is one, and so are a count and a write that
|
|
4
|
+
// needs no stored row. A write that decides from its row locks it (D-104).
|
|
5
|
+
import type { Adapter } from 'ultimabase'
|
|
6
|
+
import { defineModel, master, transaction } from 'ultimabase'
|
|
7
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
8
|
+
import { suiteUser } from './as-user.ts'
|
|
9
|
+
import { started } from './suite.ts'
|
|
10
|
+
|
|
11
|
+
const M = { as: master }
|
|
12
|
+
|
|
13
|
+
const Writer = defineModel({
|
|
14
|
+
name: 'Writer',
|
|
15
|
+
fields: (f) => ({ name: f.string(), novels: f.many(() => Novel) }),
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const Novel = defineModel({
|
|
19
|
+
name: 'Novel',
|
|
20
|
+
fields: (f) => ({
|
|
21
|
+
title: f.string(),
|
|
22
|
+
writer: f.one(() => Writer, { onDelete: 'cascade' }),
|
|
23
|
+
genres: f.many(() => Genre),
|
|
24
|
+
}),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
/** A novel's data for `createMany`, by its writer. */
|
|
28
|
+
const byWriter = (id: string) => ({ title: 'A', writer: { connect: { id } } })
|
|
29
|
+
|
|
30
|
+
const Genre = defineModel({
|
|
31
|
+
name: 'Genre',
|
|
32
|
+
fields: (f) => ({ label: f.string(), novels: f.many(() => Novel) }),
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const open = () => true
|
|
36
|
+
/** A model with rules: a user's update loads the row they decide from. */
|
|
37
|
+
const Shelf = defineModel({
|
|
38
|
+
name: 'Shelf',
|
|
39
|
+
fields: (f) => ({ label: f.string({ unique: true }) }),
|
|
40
|
+
}).with({ access: { read: open, create: open, update: open, delete: open } })
|
|
41
|
+
|
|
42
|
+
const me = '00000000-0000-4000-8000-000000000000'
|
|
43
|
+
|
|
44
|
+
let hooks = 0
|
|
45
|
+
const Cabinet = defineModel({
|
|
46
|
+
name: 'Cabinet',
|
|
47
|
+
fields: (f) => ({ label: f.string(), drawers: f.many(() => Drawer) }),
|
|
48
|
+
}).with({ access: { read: open, update: open, delete: open } })
|
|
49
|
+
|
|
50
|
+
/** A model with an update hook, which a relation write reaches. */
|
|
51
|
+
const Drawer = defineModel({
|
|
52
|
+
name: 'Drawer',
|
|
53
|
+
fields: (f) => ({
|
|
54
|
+
label: f.string(),
|
|
55
|
+
cabinet: f.one(() => Cabinet, { optional: true }),
|
|
56
|
+
}),
|
|
57
|
+
}).with({
|
|
58
|
+
access: { read: open, update: open },
|
|
59
|
+
hooks: {
|
|
60
|
+
beforeUpdate: async () => {
|
|
61
|
+
hooks += 1
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
/** Models with hooks after their writes, which run in a transaction. */
|
|
67
|
+
const Note = defineModel({
|
|
68
|
+
name: 'Note',
|
|
69
|
+
fields: (f) => ({ text: f.string() }),
|
|
70
|
+
}).with({ hooks: { afterCreate: async () => {} } })
|
|
71
|
+
|
|
72
|
+
const Memo = defineModel({
|
|
73
|
+
name: 'Memo',
|
|
74
|
+
fields: (f) => ({ text: f.string() }),
|
|
75
|
+
}).with({ hooks: { afterCreateCommit: async () => {} } })
|
|
76
|
+
|
|
77
|
+
const user = suiteUser(me)
|
|
78
|
+
|
|
79
|
+
export function roundTrips(create: () => Adapter, counted: () => number): void {
|
|
80
|
+
const trips = async <T>(
|
|
81
|
+
expected: number,
|
|
82
|
+
run: () => Promise<T>,
|
|
83
|
+
): Promise<T> => {
|
|
84
|
+
const before = counted()
|
|
85
|
+
const result = await run()
|
|
86
|
+
expect(counted() - before).toBe(expected)
|
|
87
|
+
return result
|
|
88
|
+
}
|
|
89
|
+
const once = async <T>(run: () => Promise<T>): Promise<T> => trips(1, run)
|
|
90
|
+
|
|
91
|
+
describe('round trips', () => {
|
|
92
|
+
started(create, [Writer, Novel, Genre, Shelf, Cabinet, Drawer, Note, Memo])
|
|
93
|
+
|
|
94
|
+
let writer = ''
|
|
95
|
+
let genre = ''
|
|
96
|
+
let engines = ''
|
|
97
|
+
beforeEach(async () => {
|
|
98
|
+
genre = (await Genre.create({ data: { label: 'tale' } }, M)).id
|
|
99
|
+
writer = (await Writer.create({ data: { name: 'Ada' } }, M)).id
|
|
100
|
+
const created = await Novel.create(
|
|
101
|
+
{
|
|
102
|
+
data: {
|
|
103
|
+
title: 'Engines',
|
|
104
|
+
writer: { connect: { id: writer } },
|
|
105
|
+
genres: { connect: [{ id: genre }] },
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
M,
|
|
109
|
+
)
|
|
110
|
+
engines = created.id
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('reads three levels of relations in one', async () => {
|
|
114
|
+
const writers = await once(async () =>
|
|
115
|
+
Writer.findMany(
|
|
116
|
+
{
|
|
117
|
+
include: {
|
|
118
|
+
novels: { include: { genres: { include: { novels: true } } } },
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
M,
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
expect(writers).toMatchObject([
|
|
125
|
+
{
|
|
126
|
+
novels: [
|
|
127
|
+
{ genres: [{ label: 'tale', novels: [{ title: 'Engines' }] }] },
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
])
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('reads counts and sorts through relations in one', async () => {
|
|
134
|
+
const novels = await once(async () =>
|
|
135
|
+
Novel.findMany(
|
|
136
|
+
{
|
|
137
|
+
orderBy: [
|
|
138
|
+
{ writer: { name: 'asc' } },
|
|
139
|
+
{ genres: { _count: 'desc' } },
|
|
140
|
+
],
|
|
141
|
+
include: {
|
|
142
|
+
_count: true,
|
|
143
|
+
writer: { include: { _count: { select: { novels: true } } } },
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
M,
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
expect(novels).toMatchObject([
|
|
150
|
+
{ _count: { genres: 1 }, writer: { _count: { novels: 1 } } },
|
|
151
|
+
])
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('counts in one', async () => {
|
|
155
|
+
expect(await once(async () => Novel.count({}, M))).toBe(1)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('creates in one, its connections checked', async () => {
|
|
159
|
+
const novel = await once(async () =>
|
|
160
|
+
Novel.create(
|
|
161
|
+
{
|
|
162
|
+
data: {
|
|
163
|
+
title: 'Notes',
|
|
164
|
+
writer: { connect: { id: writer } },
|
|
165
|
+
genres: { connect: [{ id: genre }] },
|
|
166
|
+
},
|
|
167
|
+
include: { writer: true, genres: true },
|
|
168
|
+
},
|
|
169
|
+
M,
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
expect(novel).toMatchObject({
|
|
173
|
+
title: 'Notes',
|
|
174
|
+
writer: { id: writer },
|
|
175
|
+
genres: [{ id: genre }],
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// A nested create is planned with its parent (D-128).
|
|
180
|
+
it('creates nested rows in one, at any depth', async () => {
|
|
181
|
+
const created = await once(async () =>
|
|
182
|
+
Writer.create(
|
|
183
|
+
{
|
|
184
|
+
data: {
|
|
185
|
+
name: 'Bo',
|
|
186
|
+
novels: {
|
|
187
|
+
create: [
|
|
188
|
+
{
|
|
189
|
+
title: 'Waves',
|
|
190
|
+
genres: {
|
|
191
|
+
connect: [{ id: genre }],
|
|
192
|
+
create: [{ label: 'sea' }],
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
include: { novels: { include: { genres: true } } },
|
|
199
|
+
},
|
|
200
|
+
M,
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
expect(created).toMatchObject({
|
|
204
|
+
novels: [{ title: 'Waves', genres: [{ id: genre }, { label: 'sea' }] }],
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('writes nothing when a connection is missing', async () => {
|
|
209
|
+
const created = once(async () =>
|
|
210
|
+
Novel.create(
|
|
211
|
+
{
|
|
212
|
+
data: {
|
|
213
|
+
title: 'Lost',
|
|
214
|
+
writer: { connect: { id: writer } },
|
|
215
|
+
genres: { connect: [{ id: genre }, { id: me }] },
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
M,
|
|
219
|
+
),
|
|
220
|
+
)
|
|
221
|
+
await expect(created).rejects.toMatchObject({ code: 'NOT_FOUND' })
|
|
222
|
+
expect(await Novel.count({}, M)).toBe(1)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('updates by id in one as Master, the row checked', async () => {
|
|
226
|
+
const updated = await once(async () =>
|
|
227
|
+
Writer.update({ where: { id: writer }, data: { name: 'Lovelace' } }, M),
|
|
228
|
+
)
|
|
229
|
+
expect(updated.name).toBe('Lovelace')
|
|
230
|
+
const missing = once(async () =>
|
|
231
|
+
Writer.update({ where: { id: me }, data: { name: 'Nobody' } }, M),
|
|
232
|
+
)
|
|
233
|
+
await expect(missing).rejects.toMatchObject({ code: 'NOT_FOUND' })
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('moves a many in one, disconnected and connected', async () => {
|
|
237
|
+
const other = (await Genre.create({ data: { label: 'saga' } }, M)).id
|
|
238
|
+
const moved = await once(async () =>
|
|
239
|
+
Novel.update(
|
|
240
|
+
{
|
|
241
|
+
where: { id: engines },
|
|
242
|
+
data: {
|
|
243
|
+
genres: {
|
|
244
|
+
disconnect: [{ id: genre }],
|
|
245
|
+
connect: [{ id: other }],
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
include: { genres: true },
|
|
249
|
+
},
|
|
250
|
+
M,
|
|
251
|
+
),
|
|
252
|
+
)
|
|
253
|
+
expect(moved.genres).toMatchObject([{ id: other }])
|
|
254
|
+
// The same genre on both sides: disconnected, then connected again.
|
|
255
|
+
const kept = await Novel.update(
|
|
256
|
+
{
|
|
257
|
+
where: { id: engines },
|
|
258
|
+
data: {
|
|
259
|
+
genres: { disconnect: [{ id: other }], connect: [{ id: other }] },
|
|
260
|
+
},
|
|
261
|
+
include: { genres: true },
|
|
262
|
+
},
|
|
263
|
+
M,
|
|
264
|
+
)
|
|
265
|
+
expect(kept.genres).toMatchObject([{ id: other }])
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('deletes in one as Master, what it reaches included', async () => {
|
|
269
|
+
// The writer, its novel and the novel's genres, the writer returned.
|
|
270
|
+
const removed = await once(async () =>
|
|
271
|
+
Writer.delete({ where: { id: writer } }, M),
|
|
272
|
+
)
|
|
273
|
+
expect(removed.name).toBe('Ada')
|
|
274
|
+
expect(await Novel.count({}, M)).toBe(0)
|
|
275
|
+
const missing = once(async () =>
|
|
276
|
+
Writer.delete({ where: { id: writer } }, M),
|
|
277
|
+
)
|
|
278
|
+
await expect(missing).rejects.toMatchObject({ code: 'NOT_FOUND' })
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
// D-135: the relations a delete returns cost one read before it.
|
|
282
|
+
it('reads the relations a delete returns once, before it', async () => {
|
|
283
|
+
const where = { id: writer }
|
|
284
|
+
const removed = await trips(4, async () =>
|
|
285
|
+
Writer.delete({ where, include: { novels: true } }, M),
|
|
286
|
+
)
|
|
287
|
+
expect(removed.novels).toMatchObject([{ title: 'Engines' }])
|
|
288
|
+
const { id } = await Cabinet.create({ data: { label: 'a' } }, M)
|
|
289
|
+
const drawers = { where: { id }, include: { drawers: true } } as const
|
|
290
|
+
// BEGIN, the row locked, read, its drawers loaded, the write, COMMIT.
|
|
291
|
+
await trips(6, async () => Cabinet.delete(drawers, { as: user }))
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('deletes many in one as Master; a user locks the rows first', async () => {
|
|
295
|
+
const removed = once(async () => Writer.deleteMany({}, M))
|
|
296
|
+
await expect(removed).resolves.toEqual({ count: 1 })
|
|
297
|
+
await Shelf.create({ data: { label: 'top' } }, M)
|
|
298
|
+
// BEGIN, the locked rows, the write, COMMIT (D-111).
|
|
299
|
+
const locked = trips(4, async () => Shelf.deleteMany({}, { as: user }))
|
|
300
|
+
await expect(locked).resolves.toEqual({ count: 1 })
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
// D-135
|
|
304
|
+
it('creates many in one, its connections checked', async () => {
|
|
305
|
+
const two = { data: [byWriter(writer), byWriter(writer)] }
|
|
306
|
+
const many = once(async () => Novel.createMany(two, M))
|
|
307
|
+
await expect(many).resolves.toEqual({ count: 2 })
|
|
308
|
+
const labels = { data: [{ label: 'a' }, { label: 'b' }] }
|
|
309
|
+
const shelves = once(async () => Shelf.createMany(labels, { as: user }))
|
|
310
|
+
await expect(shelves).resolves.toEqual({ count: 2 })
|
|
311
|
+
// BEGIN, the write, a read for each row's hook, COMMIT.
|
|
312
|
+
const texts = { data: [{ text: 'a' }, { text: 'b' }] }
|
|
313
|
+
const notes = trips(5, async () => Note.createMany(texts, M))
|
|
314
|
+
await expect(notes).resolves.toEqual({ count: 2 })
|
|
315
|
+
const missing = { data: [byWriter(writer), byWriter(me)] }
|
|
316
|
+
const refused = once(async () => Novel.createMany(missing, M))
|
|
317
|
+
await expect(refused).rejects.toMatchObject({ code: 'NOT_FOUND' })
|
|
318
|
+
expect(await Novel.count({}, M)).toBe(3)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('updates many in one as Master; a user locks the rows first', async () => {
|
|
322
|
+
const renamed = once(async () =>
|
|
323
|
+
Writer.updateMany({ data: { name: 'Bo' } }, M),
|
|
324
|
+
)
|
|
325
|
+
await expect(renamed).resolves.toEqual({ count: 1 })
|
|
326
|
+
await Cabinet.create({ data: { label: 'a' } }, M)
|
|
327
|
+
await Cabinet.create({ data: { label: 'b' } }, M)
|
|
328
|
+
// BEGIN, the locked rows, one write for both, COMMIT (D-133).
|
|
329
|
+
const locked = trips(4, async () =>
|
|
330
|
+
Cabinet.updateMany({ data: { label: 'c' } }, { as: user }),
|
|
331
|
+
)
|
|
332
|
+
await expect(locked).resolves.toEqual({ count: 2 })
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('locks the row a user updates or deletes', async () => {
|
|
336
|
+
const shelf = await once(async () =>
|
|
337
|
+
Shelf.create({ data: { label: 'top' } }, { as: user }),
|
|
338
|
+
)
|
|
339
|
+
// BEGIN, the locked row, the write, COMMIT (D-104).
|
|
340
|
+
await trips(4, async () =>
|
|
341
|
+
Shelf.update(
|
|
342
|
+
{ where: { id: shelf.id }, data: { label: 'high' } },
|
|
343
|
+
{ as: user },
|
|
344
|
+
),
|
|
345
|
+
)
|
|
346
|
+
await trips(4, async () =>
|
|
347
|
+
Shelf.delete({ where: { id: shelf.id } }, { as: user }),
|
|
348
|
+
)
|
|
349
|
+
expect(await Shelf.count({}, M)).toBe(0)
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it('locks the row before a relation write reaches update hooks', async () => {
|
|
353
|
+
const cabinet = await Cabinet.create({ data: { label: 'a' } }, M)
|
|
354
|
+
const drawer = await Drawer.create({ data: { label: 'd' } }, M)
|
|
355
|
+
hooks = 0
|
|
356
|
+
const updated = await Cabinet.update(
|
|
357
|
+
{
|
|
358
|
+
where: { id: cabinet.id },
|
|
359
|
+
data: { drawers: { connect: [{ id: drawer.id }] } },
|
|
360
|
+
include: { drawers: true },
|
|
361
|
+
},
|
|
362
|
+
{ as: user },
|
|
363
|
+
)
|
|
364
|
+
expect(updated.drawers).toMatchObject([{ id: drawer.id }])
|
|
365
|
+
expect(hooks).toBe(1)
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('runs a write with an after hook in a transaction', async () => {
|
|
369
|
+
// BEGIN, the write, the row the hook gets, the row read again, since
|
|
370
|
+
// the hook may change it, COMMIT.
|
|
371
|
+
const note = await trips(5, async () =>
|
|
372
|
+
Note.create({ data: { text: 'hi' } }, M),
|
|
373
|
+
)
|
|
374
|
+
// BEGIN, the write and its row, the row the hook gets, COMMIT.
|
|
375
|
+
const memo = await trips(4, async () =>
|
|
376
|
+
Memo.create({ data: { text: 'hi' } }, M),
|
|
377
|
+
)
|
|
378
|
+
expect([note.text, memo.text]).toEqual(['hi', 'hi'])
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it('runs every write of transaction() in one', async () => {
|
|
382
|
+
// BEGIN, two writes, COMMIT.
|
|
383
|
+
await trips(4, async () =>
|
|
384
|
+
transaction(async () => {
|
|
385
|
+
await Genre.create({ data: { label: 'myth' } }, M)
|
|
386
|
+
await Genre.create({ data: { label: 'saga' } }, M)
|
|
387
|
+
}),
|
|
388
|
+
)
|
|
389
|
+
expect(await Genre.count({}, M)).toBe(3)
|
|
390
|
+
})
|
|
391
|
+
})
|
|
392
|
+
}
|
package/src/searches.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Full-text search in the contract (D-148): `_search` keeps the rows whose
|
|
2
|
+
// searched fields hold the words of a web-style query, `_relevance` sorts
|
|
3
|
+
// them by how often those words occur, or for an and how near.
|
|
4
|
+
import type { Adapter, Identity } from 'ultimabase'
|
|
5
|
+
import { defineModel, master } from 'ultimabase'
|
|
6
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
7
|
+
import { started } from './suite.ts'
|
|
8
|
+
|
|
9
|
+
const M: { readonly as: Identity } = { as: master }
|
|
10
|
+
|
|
11
|
+
const Note = defineModel({
|
|
12
|
+
name: 'Note',
|
|
13
|
+
fields: (f) => ({
|
|
14
|
+
title: f.string({ search: true }),
|
|
15
|
+
body: f.string({ search: true, optional: true }),
|
|
16
|
+
order: f.number({ integer: true }),
|
|
17
|
+
}),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const notes = [
|
|
21
|
+
{ title: 'Red apples', body: 'apple pie and apple jam', order: 1 },
|
|
22
|
+
{ title: 'Green pears', body: 'no apple here', order: 2 },
|
|
23
|
+
{ title: 'Blue sky', order: 3 },
|
|
24
|
+
{ title: 'Apple trees', body: 'orchards', order: 4 },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
const found = async (search: string): Promise<number[]> => {
|
|
28
|
+
const rows = await Note.findMany(
|
|
29
|
+
{ where: { _search: search }, orderBy: { order: 'asc' } },
|
|
30
|
+
M,
|
|
31
|
+
)
|
|
32
|
+
return rows.map((row) => row.order)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function searches(create: () => Adapter): void {
|
|
36
|
+
describe('searches', () => {
|
|
37
|
+
started(create, [Note])
|
|
38
|
+
|
|
39
|
+
beforeEach(async () => {
|
|
40
|
+
await Note.createMany({ data: notes }, M)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('matches words across the searched fields, whatever their case', async () => {
|
|
44
|
+
expect(await found('APPLE')).toStrictEqual([1, 2, 4])
|
|
45
|
+
expect(await found('apple orchards')).toStrictEqual([4])
|
|
46
|
+
expect(await found('sky or pears')).toStrictEqual([2, 3])
|
|
47
|
+
expect(await found('apple -pears')).toStrictEqual([1, 4])
|
|
48
|
+
expect(await found('"blue sky"')).toStrictEqual([3])
|
|
49
|
+
expect(await found('"sky blue"')).toStrictEqual([])
|
|
50
|
+
expect(await found(' - "" ')).toStrictEqual([])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('ranks the rows by how often the words occur', async () => {
|
|
54
|
+
const rows = await Note.findMany(
|
|
55
|
+
{
|
|
56
|
+
where: { _search: 'apple' },
|
|
57
|
+
orderBy: [{ _relevance: 'desc' }, { order: 'desc' }],
|
|
58
|
+
},
|
|
59
|
+
M,
|
|
60
|
+
)
|
|
61
|
+
expect(rows.map((row) => row.order)).toStrictEqual([1, 4, 2])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('ranks an and of words by how near they are, none past 100 words', async () => {
|
|
65
|
+
const far = `${'filler '.repeat(100)}jam`
|
|
66
|
+
await Note.createMany(
|
|
67
|
+
{
|
|
68
|
+
data: [
|
|
69
|
+
{ title: 'Apple far', body: far, order: 6 },
|
|
70
|
+
{ title: 'Jam', body: 'one apple', order: 5 },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
M,
|
|
74
|
+
)
|
|
75
|
+
const rows = await Note.findMany(
|
|
76
|
+
{ where: { _search: 'apple jam' }, orderBy: { _relevance: 'desc' } },
|
|
77
|
+
M,
|
|
78
|
+
)
|
|
79
|
+
expect(rows.map((row) => row.order)).toStrictEqual([1, 5, 6])
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
}
|
package/src/silent.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// A logger that writes nothing, so the suite's output is the test runner's.
|
|
2
|
+
import type { Logger } from 'ultimabase'
|
|
3
|
+
|
|
4
|
+
const nothing = (): void => undefined
|
|
5
|
+
|
|
6
|
+
export const silent: Logger = {
|
|
7
|
+
info: nothing,
|
|
8
|
+
warn: nothing,
|
|
9
|
+
error: nothing,
|
|
10
|
+
child: () => silent,
|
|
11
|
+
}
|
package/src/sorts.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// The `null`s of a sort in the contract (D-149): first or last as `nulls`
|
|
2
|
+
// says, by default last ascending and first descending, in a read, through
|
|
3
|
+
// a `one` relation, with a cursor, with `distinct` and in `groupBy`.
|
|
4
|
+
import type { Adapter, ArgsOf } from 'ultimabase'
|
|
5
|
+
import { defineModel, master } from 'ultimabase'
|
|
6
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
7
|
+
import { started } from './suite.ts'
|
|
8
|
+
|
|
9
|
+
const M = { as: master }
|
|
10
|
+
|
|
11
|
+
const Writer = defineModel({
|
|
12
|
+
name: 'Writer',
|
|
13
|
+
fields: (f) => ({
|
|
14
|
+
nick: f.string({ optional: true }),
|
|
15
|
+
notes: f.many(() => Note),
|
|
16
|
+
}),
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const Note = defineModel({
|
|
20
|
+
name: 'Note',
|
|
21
|
+
fields: (f) => ({
|
|
22
|
+
title: f.string({ unique: true }),
|
|
23
|
+
rank: f.number({ optional: true }),
|
|
24
|
+
writer: f.one(() => Writer, { optional: true }),
|
|
25
|
+
}),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
type Args = Pick<
|
|
29
|
+
ArgsOf<typeof Note, 'findMany'>,
|
|
30
|
+
'orderBy' | 'cursor' | 'take' | 'distinct'
|
|
31
|
+
>
|
|
32
|
+
const titles = async (args: Args): Promise<string[]> =>
|
|
33
|
+
(await Note.findMany({ ...args }, M)).map((note) => note.title)
|
|
34
|
+
const rank = async (orderBy: Args['orderBy']): Promise<string[]> =>
|
|
35
|
+
titles({ orderBy })
|
|
36
|
+
|
|
37
|
+
export function sorts(create: () => Adapter): void {
|
|
38
|
+
describe('sorts', () => {
|
|
39
|
+
started(create, [Writer, Note])
|
|
40
|
+
|
|
41
|
+
// a and d by x, b by a writer with no nick, c by none.
|
|
42
|
+
beforeEach(async () => {
|
|
43
|
+
const x = await Writer.create({ data: { nick: 'x' } }, M)
|
|
44
|
+
const anon = await Writer.create({ data: {} }, M)
|
|
45
|
+
const notes = [
|
|
46
|
+
{ title: 'a', rank: 2, writer: x.id },
|
|
47
|
+
{ title: 'b', rank: null, writer: anon.id },
|
|
48
|
+
{ title: 'c', rank: 1 },
|
|
49
|
+
{ title: 'd', rank: null, writer: x.id },
|
|
50
|
+
]
|
|
51
|
+
for (const { writer, ...data } of notes) {
|
|
52
|
+
const connected =
|
|
53
|
+
writer === undefined
|
|
54
|
+
? data
|
|
55
|
+
: { ...data, writer: { connect: { id: writer } } }
|
|
56
|
+
await Note.create({ data: connected }, M)
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('puts the nulls first or last', async () => {
|
|
61
|
+
expect(await rank({ rank: { sort: 'asc', nulls: 'first' } })).toEqual([
|
|
62
|
+
'b',
|
|
63
|
+
'd',
|
|
64
|
+
'c',
|
|
65
|
+
'a',
|
|
66
|
+
])
|
|
67
|
+
expect(await rank({ rank: { sort: 'desc', nulls: 'last' } })).toEqual([
|
|
68
|
+
'a',
|
|
69
|
+
'c',
|
|
70
|
+
'b',
|
|
71
|
+
'd',
|
|
72
|
+
])
|
|
73
|
+
expect(await rank({ rank: { sort: 'asc' } })).toEqual([
|
|
74
|
+
'c',
|
|
75
|
+
'a',
|
|
76
|
+
'b',
|
|
77
|
+
'd',
|
|
78
|
+
])
|
|
79
|
+
expect(await rank({ rank: { sort: 'desc', nulls: 'first' } })).toEqual([
|
|
80
|
+
'b',
|
|
81
|
+
'd',
|
|
82
|
+
'a',
|
|
83
|
+
'c',
|
|
84
|
+
])
|
|
85
|
+
// A row the relation does not reach has a null too.
|
|
86
|
+
expect(
|
|
87
|
+
await rank({ writer: { nick: { sort: 'asc', nulls: 'first' } } }),
|
|
88
|
+
).toEqual(['b', 'c', 'a', 'd'])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('pages from a cursor where the nulls sort, forwards and backwards', async () => {
|
|
92
|
+
const last = { rank: { sort: 'desc', nulls: 'last' } } as const
|
|
93
|
+
const first = { rank: { sort: 'asc', nulls: 'first' } } as const
|
|
94
|
+
const from = (title: string, orderBy: Args['orderBy'], take: number) =>
|
|
95
|
+
titles({ orderBy, cursor: { title }, take })
|
|
96
|
+
expect(await from('c', last, 2)).toEqual(['c', 'b'])
|
|
97
|
+
expect(await from('c', last, -2)).toEqual(['a', 'c'])
|
|
98
|
+
expect(await from('b', last, 2)).toEqual(['b', 'd'])
|
|
99
|
+
expect(await from('b', last, -2)).toEqual(['c', 'b'])
|
|
100
|
+
expect(await from('d', first, 2)).toEqual(['d', 'c'])
|
|
101
|
+
expect(await from('d', first, -2)).toEqual(['b', 'd'])
|
|
102
|
+
expect(await titles({ orderBy: first, take: -3 })).toEqual([
|
|
103
|
+
'd',
|
|
104
|
+
'c',
|
|
105
|
+
'a',
|
|
106
|
+
])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('keeps the first row of each distinct key where the nulls sort', async () => {
|
|
110
|
+
expect(
|
|
111
|
+
await titles({
|
|
112
|
+
distinct: ['writer'],
|
|
113
|
+
orderBy: { rank: { sort: 'asc', nulls: 'first' } },
|
|
114
|
+
}),
|
|
115
|
+
).toEqual(['b', 'd', 'c'])
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('sorts the groups of an optional key with their null', async () => {
|
|
119
|
+
const ranks = async (nulls: 'first' | 'last') =>
|
|
120
|
+
(
|
|
121
|
+
await Note.groupBy(
|
|
122
|
+
{ by: ['rank'], orderBy: { rank: { sort: 'desc', nulls } } },
|
|
123
|
+
M,
|
|
124
|
+
)
|
|
125
|
+
).map((group) => group.rank)
|
|
126
|
+
expect(await ranks('last')).toEqual([2, 1, null])
|
|
127
|
+
expect(await ranks('first')).toEqual([null, 2, 1])
|
|
128
|
+
const writers = await Note.groupBy(
|
|
129
|
+
{
|
|
130
|
+
by: ['writer'],
|
|
131
|
+
_count: true,
|
|
132
|
+
orderBy: [
|
|
133
|
+
{ _count: 'desc' },
|
|
134
|
+
{ writer: { sort: 'asc', nulls: 'first' } },
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
M,
|
|
138
|
+
)
|
|
139
|
+
expect(writers.map((group) => group._count)).toEqual([2, 1, 1])
|
|
140
|
+
expect(writers[1]?.writer).toBeNull()
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
}
|
package/src/suite.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The setup the contract suites share: each test on a new database, its
|
|
2
|
+
// application started before and stopped after.
|
|
3
|
+
import type { Adapter } from 'ultimabase'
|
|
4
|
+
import { defineApp } from 'ultimabase'
|
|
5
|
+
import { afterEach, beforeEach } from 'vitest'
|
|
6
|
+
import { silent } from './silent.ts'
|
|
7
|
+
|
|
8
|
+
type Models = Parameters<typeof defineApp>[0]['models']
|
|
9
|
+
|
|
10
|
+
/** Starts `models` on a new database for each test; reads that database. */
|
|
11
|
+
export function started(create: () => Adapter, models: Models): () => Adapter {
|
|
12
|
+
let database: Adapter
|
|
13
|
+
let app: ReturnType<typeof defineApp>
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
database = create()
|
|
16
|
+
app = defineApp({ database, models, logger: silent })
|
|
17
|
+
await app.start()
|
|
18
|
+
})
|
|
19
|
+
afterEach(async () => app.stop())
|
|
20
|
+
return () => database
|
|
21
|
+
}
|