@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.
Files changed (119) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +4 -2
  3. package/dist/aggregates.d.ts +3 -0
  4. package/dist/aggregates.d.ts.map +1 -0
  5. package/dist/aggregates.js +99 -0
  6. package/dist/as-user.d.ts +13 -0
  7. package/dist/as-user.d.ts.map +1 -0
  8. package/dist/as-user.js +17 -0
  9. package/dist/bulk-returns.d.ts +3 -0
  10. package/dist/bulk-returns.d.ts.map +1 -0
  11. package/dist/bulk-returns.js +125 -0
  12. package/dist/cascades.d.ts +3 -0
  13. package/dist/cascades.d.ts.map +1 -0
  14. package/dist/cascades.js +269 -0
  15. package/dist/cases.d.ts +3 -0
  16. package/dist/cases.d.ts.map +1 -0
  17. package/dist/cases.js +60 -0
  18. package/dist/counts.d.ts +3 -0
  19. package/dist/counts.d.ts.map +1 -0
  20. package/dist/counts.js +158 -0
  21. package/dist/days.d.ts +3 -0
  22. package/dist/days.d.ts.map +1 -0
  23. package/dist/days.js +74 -0
  24. package/dist/decimals.d.ts +3 -0
  25. package/dist/decimals.d.ts.map +1 -0
  26. package/dist/decimals.js +70 -0
  27. package/dist/distincts.d.ts +3 -0
  28. package/dist/distincts.d.ts.map +1 -0
  29. package/dist/distincts.js +80 -0
  30. package/dist/feeds.d.ts +3 -0
  31. package/dist/feeds.d.ts.map +1 -0
  32. package/dist/feeds.js +164 -0
  33. package/dist/groups.d.ts +3 -0
  34. package/dist/groups.d.ts.map +1 -0
  35. package/dist/groups.js +149 -0
  36. package/dist/index.d.ts +5 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +5 -0
  39. package/dist/json-paths.d.ts +3 -0
  40. package/dist/json-paths.d.ts.map +1 -0
  41. package/dist/json-paths.js +143 -0
  42. package/dist/lists.d.ts +3 -0
  43. package/dist/lists.d.ts.map +1 -0
  44. package/dist/lists.js +234 -0
  45. package/dist/nested.d.ts +3 -0
  46. package/dist/nested.d.ts.map +1 -0
  47. package/dist/nested.js +152 -0
  48. package/dist/numbers.d.ts +3 -0
  49. package/dist/numbers.d.ts.map +1 -0
  50. package/dist/numbers.js +92 -0
  51. package/dist/projection.d.ts +3 -0
  52. package/dist/projection.d.ts.map +1 -0
  53. package/dist/projection.js +53 -0
  54. package/dist/reads.d.ts +3 -0
  55. package/dist/reads.d.ts.map +1 -0
  56. package/dist/reads.js +312 -0
  57. package/dist/relation-filters.d.ts +3 -0
  58. package/dist/relation-filters.d.ts.map +1 -0
  59. package/dist/relation-filters.js +116 -0
  60. package/dist/round-trips.d.ts +3 -0
  61. package/dist/round-trips.d.ts.map +1 -0
  62. package/dist/round-trips.js +284 -0
  63. package/dist/searches.d.ts +3 -0
  64. package/dist/searches.d.ts.map +1 -0
  65. package/dist/searches.js +57 -0
  66. package/dist/silent.d.ts +3 -0
  67. package/dist/silent.d.ts.map +1 -0
  68. package/dist/silent.js +7 -0
  69. package/dist/sorts.d.ts +3 -0
  70. package/dist/sorts.d.ts.map +1 -0
  71. package/dist/sorts.js +108 -0
  72. package/dist/suite.d.ts +7 -0
  73. package/dist/suite.d.ts.map +1 -0
  74. package/dist/suite.js +15 -0
  75. package/dist/test-adapter.d.ts +11 -0
  76. package/dist/test-adapter.d.ts.map +1 -0
  77. package/dist/test-adapter.js +55 -0
  78. package/dist/test-application.d.ts +17 -0
  79. package/dist/test-application.d.ts.map +1 -0
  80. package/dist/test-application.js +68 -0
  81. package/dist/transactions.d.ts +3 -0
  82. package/dist/transactions.d.ts.map +1 -0
  83. package/dist/transactions.js +61 -0
  84. package/dist/virtuals.d.ts +3 -0
  85. package/dist/virtuals.d.ts.map +1 -0
  86. package/dist/virtuals.js +135 -0
  87. package/dist/writes.d.ts +3 -0
  88. package/dist/writes.d.ts.map +1 -0
  89. package/dist/writes.js +219 -0
  90. package/package.json +40 -3
  91. package/src/aggregates.ts +128 -0
  92. package/src/as-user.ts +26 -0
  93. package/src/bulk-returns.ts +151 -0
  94. package/src/cascades.ts +338 -0
  95. package/src/cases.ts +76 -0
  96. package/src/counts.ts +223 -0
  97. package/src/days.ts +107 -0
  98. package/src/decimals.ts +102 -0
  99. package/src/distincts.ts +107 -0
  100. package/src/feeds.ts +197 -0
  101. package/src/groups.ts +185 -0
  102. package/src/index.ts +6 -0
  103. package/src/json-paths.ts +185 -0
  104. package/src/lists.ts +304 -0
  105. package/src/nested.ts +187 -0
  106. package/src/numbers.ts +125 -0
  107. package/src/projection.ts +65 -0
  108. package/src/reads.ts +389 -0
  109. package/src/relation-filters.ts +151 -0
  110. package/src/round-trips.ts +392 -0
  111. package/src/searches.ts +82 -0
  112. package/src/silent.ts +11 -0
  113. package/src/sorts.ts +143 -0
  114. package/src/suite.ts +21 -0
  115. package/src/test-adapter.ts +68 -0
  116. package/src/test-application.ts +93 -0
  117. package/src/transactions.ts +76 -0
  118. package/src/virtuals.ts +184 -0
  119. package/src/writes.ts +315 -0
package/src/writes.ts ADDED
@@ -0,0 +1,315 @@
1
+ // The writes of the contract (D-73): create, update and delete, the links of
2
+ // every relation, and the refusals of a unique key, of a missing row and of
3
+ // a value PostgreSQL cannot store as written (D-94).
4
+ import type { Adapter } from 'ultimabase'
5
+ import { defineModel, master } from 'ultimabase'
6
+ import { describe, expect, it } from 'vitest'
7
+ import { started } from './suite.ts'
8
+
9
+ const M = { as: master }
10
+
11
+ const Team = defineModel({
12
+ name: 'Team',
13
+ fields: (f) => ({
14
+ name: f.string({ unique: true }),
15
+ players: f.many(() => Player),
16
+ }),
17
+ })
18
+
19
+ const Player = defineModel({
20
+ name: 'Player',
21
+ fields: (f) => ({
22
+ name: f.string(),
23
+ score: f.number({ default: 0 }),
24
+ active: f.boolean({ default: true }),
25
+ bio: f.string({ optional: true }),
26
+ stats: f.json({ optional: true }),
27
+ bornAt: f.dateTime({ optional: true }),
28
+ team: f.one(() => Team, { optional: true }),
29
+ badge: f.one(() => Badge, { optional: true }),
30
+ skills: f.many(() => Skill),
31
+ }),
32
+ indexes: [{ fields: ['name', 'team'], unique: true }],
33
+ })
34
+
35
+ const Badge = defineModel({
36
+ name: 'Badge',
37
+ fields: (f) => ({ code: f.string(), player: f.one(() => Player) }),
38
+ })
39
+
40
+ const Skill = defineModel({
41
+ name: 'Skill',
42
+ fields: (f) => ({
43
+ label: f.string({ unique: true }),
44
+ players: f.many(() => Player),
45
+ }),
46
+ })
47
+
48
+ const skillsOf = async (id: string): Promise<string[]> =>
49
+ (
50
+ await Skill.findMany(
51
+ { where: { players: { some: { id } } }, orderBy: { label: 'asc' } },
52
+ M,
53
+ )
54
+ ).map((skill) => skill.label)
55
+ const skill = async (label: string) => Skill.create({ data: { label } }, M)
56
+
57
+ export function writes(create: () => Adapter): void {
58
+ describe('writes', () => {
59
+ const database = started(create, [Team, Player, Badge, Skill])
60
+
61
+ it('creates a row and returns it as stored', async () => {
62
+ const bornAt = new Date(Date.UTC(1990, 4, 17, 8, 30, 15, 250))
63
+ const created = await Player.create(
64
+ { data: { name: 'Ada', stats: { goals: [1, 2], ok: true }, bornAt } },
65
+ M,
66
+ )
67
+ expect(created).toMatchObject({
68
+ name: 'Ada',
69
+ score: 0,
70
+ active: true,
71
+ bio: null,
72
+ stats: { goals: [1, 2], ok: true },
73
+ bornAt,
74
+ })
75
+ expect(created.createdAt).toBeInstanceOf(Date)
76
+ expect(await Player.findUnique({ where: { id: created.id } }, M)).toEqual(
77
+ created,
78
+ )
79
+ })
80
+
81
+ it('updates values, to null too, and moves updatedAt', async () => {
82
+ const ada = await Player.create(
83
+ { data: { name: 'Ada', bio: 'Poet', score: 1.5 } },
84
+ M,
85
+ )
86
+ const updated = await Player.update(
87
+ {
88
+ where: { id: ada.id },
89
+ data: { bio: null, score: -2, active: false },
90
+ },
91
+ M,
92
+ )
93
+ expect(updated).toMatchObject({ bio: null, score: -2, active: false })
94
+ expect(updated.createdAt).toEqual(ada.createdAt)
95
+ expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(
96
+ ada.updatedAt.getTime(),
97
+ )
98
+ expect(await Player.findUnique({ where: { id: ada.id } }, M)).toEqual(
99
+ updated,
100
+ )
101
+ })
102
+
103
+ it('deletes a row and returns it', async () => {
104
+ const ada = await Player.create({ data: { name: 'Ada' } }, M)
105
+ expect(await Player.delete({ where: { id: ada.id } }, M)).toEqual(ada)
106
+ expect(await Player.count({}, M)).toBe(0)
107
+ await expect(
108
+ Player.delete({ where: { id: ada.id } }, M),
109
+ ).rejects.toMatchObject({ code: 'NOT_FOUND' })
110
+ })
111
+
112
+ it('reads, with before, the rows its delete removed, as they were', async () => {
113
+ const ada = await Player.create({ data: { name: 'Ada' } }, M)
114
+ await Player.create({ data: { name: 'Bob' } }, M)
115
+ const read = { model: 'Player', where: {} }
116
+ const where = { name: { equals: 'Ada' } }
117
+ const removed = await database().write({
118
+ changes: [{ kind: 'delete', model: 'Player', where }],
119
+ read,
120
+ before: true,
121
+ })
122
+ expect(removed).toMatchObject([{ id: ada.id, name: 'Ada' }])
123
+ // Nothing left to remove, or no delete at all: no row (D-104).
124
+ const again = await database().write({
125
+ changes: [{ kind: 'delete', model: 'Player', where }],
126
+ read,
127
+ before: true,
128
+ })
129
+ expect(again).toEqual([])
130
+ expect(
131
+ await database().write({ changes: [], read, before: true }),
132
+ ).toEqual([])
133
+ expect(await Player.count({}, M)).toBe(1)
134
+ })
135
+
136
+ it('reads, with before and no delete, the rows its update changed', async () => {
137
+ const ada = await Player.create({ data: { name: 'Ada' } }, M)
138
+ await Player.create({ data: { name: 'Bob' } }, M)
139
+ const changed = await database().write({
140
+ changes: [
141
+ {
142
+ kind: 'update',
143
+ model: 'Player',
144
+ where: { name: { equals: 'Ada' } },
145
+ set: { name: 'Eve' },
146
+ },
147
+ ],
148
+ read: { model: 'Player', where: {} },
149
+ before: true,
150
+ })
151
+ // As the update left them (D-133).
152
+ expect(changed).toMatchObject([{ id: ada.id, name: 'Eve' }])
153
+ })
154
+
155
+ it('connects and disconnects a one-to-many', async () => {
156
+ const team = await Team.create({ data: { name: 'Reds' } }, M)
157
+ const ada = await Player.create(
158
+ { data: { name: 'Ada', team: { connect: { id: team.id } } } },
159
+ M,
160
+ )
161
+ const reds = await Team.findUnique(
162
+ { where: { id: team.id }, include: { players: true } },
163
+ M,
164
+ )
165
+ expect(reds).toMatchObject({ players: [{ id: ada.id }] })
166
+ await Player.update(
167
+ { where: { id: ada.id }, data: { team: { disconnect: true } } },
168
+ M,
169
+ )
170
+ expect(await Player.count({ where: { team: { is: null } } }, M)).toBe(1)
171
+ })
172
+
173
+ it('connects a one-to-one from either side', async () => {
174
+ const ada = await Player.create({ data: { name: 'Ada' } }, M)
175
+ const badge = await Badge.create(
176
+ { data: { code: 'A1', player: { connect: { id: ada.id } } } },
177
+ M,
178
+ )
179
+ const found = await Player.findUnique(
180
+ { where: { id: ada.id }, include: { badge: true } },
181
+ M,
182
+ )
183
+ expect(found).toMatchObject({ badge: { id: badge.id } })
184
+ const grace = await Player.create(
185
+ { data: { name: 'Grace', badge: { connect: { id: badge.id } } } },
186
+ M,
187
+ )
188
+ const moved = await Badge.findUnique(
189
+ { where: { id: badge.id }, include: { player: true } },
190
+ M,
191
+ )
192
+ expect(moved).toMatchObject({ player: { id: grace.id } })
193
+ })
194
+
195
+ it('links and unlinks a many-to-many, and unlinks a deleted row', async () => {
196
+ const math = await skill('math')
197
+ const art = await skill('art')
198
+ const chess = await skill('chess')
199
+ const ada = await Player.create(
200
+ {
201
+ data: {
202
+ name: 'Ada',
203
+ skills: {
204
+ connect: [{ id: math.id }, { id: art.id }],
205
+ },
206
+ },
207
+ },
208
+ M,
209
+ )
210
+ expect(await skillsOf(ada.id)).toEqual(['art', 'math'])
211
+ await Player.update(
212
+ {
213
+ where: { id: ada.id },
214
+ data: {
215
+ skills: {
216
+ connect: [{ id: chess.id }],
217
+ disconnect: [{ id: math.id }],
218
+ },
219
+ },
220
+ },
221
+ M,
222
+ )
223
+ expect(await skillsOf(ada.id)).toEqual(['art', 'chess'])
224
+ await Skill.delete({ where: { id: art.id } }, M)
225
+ expect(await skillsOf(ada.id)).toEqual(['chess'])
226
+ await Player.delete({ where: { id: ada.id } }, M)
227
+ expect(await Skill.count({ where: { players: { some: {} } } }, M)).toBe(0)
228
+ })
229
+
230
+ it('refuses a second row with a unique field or index, and keeps the first', async () => {
231
+ const team = await Team.create({ data: { name: 'Reds' } }, M)
232
+ await expect(
233
+ Team.create({ data: { name: 'Reds' } }, M),
234
+ ).rejects.toMatchObject({
235
+ code: 'UNIQUE_VIOLATION',
236
+ details: { model: 'Team', fields: ['name'] },
237
+ })
238
+ const data = { name: 'Ada', team: { connect: { id: team.id } } }
239
+ await Player.create({ data }, M)
240
+ await expect(Player.create({ data }, M)).rejects.toMatchObject({
241
+ code: 'UNIQUE_VIOLATION',
242
+ details: { model: 'Player', fields: ['name', 'team'] },
243
+ })
244
+ const grace = await Player.create({ data: { name: 'Grace' } }, M)
245
+ await expect(
246
+ Player.update(
247
+ { where: { id: grace.id }, data: { name: 'Ada', team: data.team } },
248
+ M,
249
+ ),
250
+ ).rejects.toMatchObject({ code: 'UNIQUE_VIOLATION' })
251
+ expect(await Team.count({}, M)).toBe(1)
252
+ expect(
253
+ await Player.count({ where: { name: 'Grace', team: { is: null } } }, M),
254
+ ).toBe(1)
255
+ })
256
+
257
+ it('stores any well-formed text and the dates of years 1 to 9999', async () => {
258
+ const first = new Date('0001-01-01T00:00:00.000Z')
259
+ const last = new Date('9999-12-31T23:59:59.999Z')
260
+ const ada = await Player.create(
261
+ { data: { name: '😀 Ada', stats: { 'é😀': ['z'] }, bornAt: first } },
262
+ M,
263
+ )
264
+ await Player.create({ data: { name: 'Grace', bornAt: last } }, M)
265
+ expect(
266
+ await Player.findMany({ orderBy: { bornAt: 'asc' } }, M),
267
+ ).toMatchObject([
268
+ { name: '😀 Ada', stats: { 'é😀': ['z'] }, bornAt: first },
269
+ { name: 'Grace', bornAt: last },
270
+ ])
271
+ expect(
272
+ await Player.count({ where: { id: ada.id, bornAt: first } }, M),
273
+ ).toBe(1)
274
+ })
275
+
276
+ it('refuses a NUL, a lone surrogate or a year outside 1 to 9999', async () => {
277
+ const invalid = { code: 'VALIDATION_FAILED' }
278
+ for (const data of [
279
+ { name: 'A\0da' },
280
+ { name: '\uD800' },
281
+ { name: 'Ada', stats: { 'k\0': 1 } },
282
+ { name: 'Ada', stats: ['\uDC00'] },
283
+ { name: 'Ada', bornAt: new Date('0000-12-31T23:59:59.999Z') },
284
+ { name: 'Ada', bornAt: new Date('+010000-01-01T00:00:00.000Z') },
285
+ ]) {
286
+ await expect(Player.create({ data }, M)).rejects.toMatchObject(invalid)
287
+ }
288
+ const query = { code: 'INVALID_QUERY' }
289
+ const late = new Date('+010000-01-01T00:00:00.000Z')
290
+ for (const where of [
291
+ { name: 'A\0' },
292
+ { name: { contains: '\uD800' } },
293
+ { id: { in: ['\0'] } },
294
+ { bornAt: { lt: late } },
295
+ { stats: { equals: { a: '\0' } } },
296
+ ]) {
297
+ await expect(Player.findMany({ where }, M)).rejects.toMatchObject(query)
298
+ }
299
+ const team = { connect: { id: '\0' } }
300
+ await expect(
301
+ Player.create({ data: { name: 'Ada', team } }, M),
302
+ ).rejects.toMatchObject(query)
303
+ expect(await Player.count({}, M)).toBe(0)
304
+ })
305
+
306
+ it('refuses to connect a missing row, and writes nothing', async () => {
307
+ const created = Player.create(
308
+ { data: { name: 'Ada', team: { connect: { id: 'missing' } } } },
309
+ M,
310
+ )
311
+ await expect(created).rejects.toMatchObject({ code: 'NOT_FOUND' })
312
+ expect(await Player.count({}, M)).toBe(0)
313
+ })
314
+ })
315
+ }