@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
@@ -0,0 +1,116 @@
1
+ import { allow, defineModel, master } from 'ultimabase';
2
+ import { beforeEach, describe, expect, it } from 'vitest';
3
+ import { started } from './suite.js';
4
+ const M = { as: master };
5
+ const Writer = defineModel({
6
+ name: 'Writer',
7
+ fields: (f) => ({
8
+ name: f.string({ unique: true }),
9
+ notes: f.many(() => Note),
10
+ labels: f.many(() => Label),
11
+ drafts: f.many(() => Note, { from: 'notes' }),
12
+ secrets: f.many(() => Label, { from: 'labels' }),
13
+ }),
14
+ }).with({
15
+ access: allow.public,
16
+ relations: {
17
+ notes: { where: ({ where }) => where({ published: true }) },
18
+ labels: { where: ({ where }) => where({ hidden: false }) },
19
+ drafts: { where: ({ where }) => where({ published: false }) },
20
+ secrets: { where: ({ where }) => where({ hidden: true }) },
21
+ },
22
+ });
23
+ const Note = defineModel({
24
+ name: 'Note',
25
+ fields: (f) => ({
26
+ title: f.string(),
27
+ published: f.boolean({ default: false }),
28
+ writer: f.one(() => Writer, { optional: true }),
29
+ }),
30
+ }).with({ access: allow.public });
31
+ const Label = defineModel({
32
+ name: 'Label',
33
+ fields: (f) => ({
34
+ name: f.string(),
35
+ hidden: f.boolean({ default: false }),
36
+ writers: f.many(() => Writer),
37
+ }),
38
+ }).with({ access: allow.public });
39
+ const names = async (where) => Writer.findMany({ where, orderBy: { name: 'asc' }, select: { name: true } }, M);
40
+ const named = (...list) => list.map((name) => ({ name }));
41
+ export function relationFilters(create) {
42
+ describe('filtered relations', () => {
43
+ started(create, [Writer, Note, Label]);
44
+ beforeEach(async () => {
45
+ await Writer.create({
46
+ data: {
47
+ name: 'Ada',
48
+ notes: {
49
+ create: [
50
+ { title: 'One', published: true },
51
+ { title: 'Draft' },
52
+ { title: 'Two', published: true },
53
+ ],
54
+ },
55
+ labels: {
56
+ create: [{ name: 'open' }, { name: 'secret', hidden: true }],
57
+ },
58
+ },
59
+ }, M);
60
+ await Writer.create({ data: { name: 'Bob', notes: { create: [{ title: 'Bob draft' }] } } }, M);
61
+ });
62
+ it('includes and counts the rows the filter keeps', async () => {
63
+ const rows = await Writer.findMany({
64
+ orderBy: [{ notes: { _count: 'desc' } }, { name: 'asc' }],
65
+ select: {
66
+ name: true,
67
+ notes: { orderBy: { title: 'asc' }, select: { title: true } },
68
+ labels: { select: { name: true } },
69
+ _count: { select: { notes: true, labels: true } },
70
+ },
71
+ }, M);
72
+ expect(rows).toEqual([
73
+ {
74
+ name: 'Ada',
75
+ notes: [{ title: 'One' }, { title: 'Two' }],
76
+ labels: [{ name: 'open' }],
77
+ _count: { notes: 2, labels: 1 },
78
+ },
79
+ { name: 'Bob', notes: [], labels: [], _count: { notes: 0, labels: 0 } },
80
+ ]);
81
+ });
82
+ it('filters some, every and none', async () => {
83
+ expect(await names({ notes: { some: { title: 'Draft' } } })).toEqual([]);
84
+ expect(await names({ labels: { some: { hidden: true } } })).toEqual([]);
85
+ expect(await names({ notes: { every: { published: true } } })).toEqual(named('Ada', 'Bob'));
86
+ expect(await names({ notes: { none: {} } })).toEqual(named('Bob'));
87
+ });
88
+ it('reads a view through the link of its relation', async () => {
89
+ const rows = await Writer.findMany({
90
+ orderBy: [{ secrets: { _count: 'desc' } }, { name: 'asc' }],
91
+ select: {
92
+ name: true,
93
+ drafts: { select: { title: true } },
94
+ secrets: { select: { name: true } },
95
+ _count: { select: { drafts: true, secrets: true } },
96
+ },
97
+ }, M);
98
+ expect(rows).toEqual([
99
+ {
100
+ name: 'Ada',
101
+ drafts: [{ title: 'Draft' }],
102
+ secrets: [{ name: 'secret' }],
103
+ _count: { drafts: 1, secrets: 1 },
104
+ },
105
+ {
106
+ name: 'Bob',
107
+ drafts: [{ title: 'Bob draft' }],
108
+ secrets: [],
109
+ _count: { drafts: 1, secrets: 0 },
110
+ },
111
+ ]);
112
+ expect(await names({ secrets: { some: {} } })).toEqual(named('Ada'));
113
+ expect(await names({ drafts: { every: { title: 'Draft' } } })).toEqual(named('Ada'));
114
+ });
115
+ });
116
+ }
@@ -0,0 +1,3 @@
1
+ import type { Adapter } from 'ultimabase';
2
+ export declare function roundTrips(create: () => Adapter, counted: () => number): void;
3
+ //# sourceMappingURL=round-trips.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"round-trips.d.ts","sourceRoot":"","sources":["../src/round-trips.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAA;AA0EzC,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,OAAO,EAAE,OAAO,EAAE,MAAM,MAAM,GAAG,IAAI,CAyT7E"}
@@ -0,0 +1,284 @@
1
+ import { defineModel, master, transaction } from 'ultimabase';
2
+ import { beforeEach, describe, expect, it } from 'vitest';
3
+ import { suiteUser } from './as-user.js';
4
+ import { started } from './suite.js';
5
+ const M = { as: master };
6
+ const Writer = defineModel({
7
+ name: 'Writer',
8
+ fields: (f) => ({ name: f.string(), novels: f.many(() => Novel) }),
9
+ });
10
+ const Novel = defineModel({
11
+ name: 'Novel',
12
+ fields: (f) => ({
13
+ title: f.string(),
14
+ writer: f.one(() => Writer, { onDelete: 'cascade' }),
15
+ genres: f.many(() => Genre),
16
+ }),
17
+ });
18
+ /** A novel's data for `createMany`, by its writer. */
19
+ const byWriter = (id) => ({ title: 'A', writer: { connect: { id } } });
20
+ const Genre = defineModel({
21
+ name: 'Genre',
22
+ fields: (f) => ({ label: f.string(), novels: f.many(() => Novel) }),
23
+ });
24
+ const open = () => true;
25
+ /** A model with rules: a user's update loads the row they decide from. */
26
+ const Shelf = defineModel({
27
+ name: 'Shelf',
28
+ fields: (f) => ({ label: f.string({ unique: true }) }),
29
+ }).with({ access: { read: open, create: open, update: open, delete: open } });
30
+ const me = '00000000-0000-4000-8000-000000000000';
31
+ let hooks = 0;
32
+ const Cabinet = defineModel({
33
+ name: 'Cabinet',
34
+ fields: (f) => ({ label: f.string(), drawers: f.many(() => Drawer) }),
35
+ }).with({ access: { read: open, update: open, delete: open } });
36
+ /** A model with an update hook, which a relation write reaches. */
37
+ const Drawer = defineModel({
38
+ name: 'Drawer',
39
+ fields: (f) => ({
40
+ label: f.string(),
41
+ cabinet: f.one(() => Cabinet, { optional: true }),
42
+ }),
43
+ }).with({
44
+ access: { read: open, update: open },
45
+ hooks: {
46
+ beforeUpdate: async () => {
47
+ hooks += 1;
48
+ },
49
+ },
50
+ });
51
+ /** Models with hooks after their writes, which run in a transaction. */
52
+ const Note = defineModel({
53
+ name: 'Note',
54
+ fields: (f) => ({ text: f.string() }),
55
+ }).with({ hooks: { afterCreate: async () => { } } });
56
+ const Memo = defineModel({
57
+ name: 'Memo',
58
+ fields: (f) => ({ text: f.string() }),
59
+ }).with({ hooks: { afterCreateCommit: async () => { } } });
60
+ const user = suiteUser(me);
61
+ export function roundTrips(create, counted) {
62
+ const trips = async (expected, run) => {
63
+ const before = counted();
64
+ const result = await run();
65
+ expect(counted() - before).toBe(expected);
66
+ return result;
67
+ };
68
+ const once = async (run) => trips(1, run);
69
+ describe('round trips', () => {
70
+ started(create, [Writer, Novel, Genre, Shelf, Cabinet, Drawer, Note, Memo]);
71
+ let writer = '';
72
+ let genre = '';
73
+ let engines = '';
74
+ beforeEach(async () => {
75
+ genre = (await Genre.create({ data: { label: 'tale' } }, M)).id;
76
+ writer = (await Writer.create({ data: { name: 'Ada' } }, M)).id;
77
+ const created = await Novel.create({
78
+ data: {
79
+ title: 'Engines',
80
+ writer: { connect: { id: writer } },
81
+ genres: { connect: [{ id: genre }] },
82
+ },
83
+ }, M);
84
+ engines = created.id;
85
+ });
86
+ it('reads three levels of relations in one', async () => {
87
+ const writers = await once(async () => Writer.findMany({
88
+ include: {
89
+ novels: { include: { genres: { include: { novels: true } } } },
90
+ },
91
+ }, M));
92
+ expect(writers).toMatchObject([
93
+ {
94
+ novels: [
95
+ { genres: [{ label: 'tale', novels: [{ title: 'Engines' }] }] },
96
+ ],
97
+ },
98
+ ]);
99
+ });
100
+ it('reads counts and sorts through relations in one', async () => {
101
+ const novels = await once(async () => Novel.findMany({
102
+ orderBy: [
103
+ { writer: { name: 'asc' } },
104
+ { genres: { _count: 'desc' } },
105
+ ],
106
+ include: {
107
+ _count: true,
108
+ writer: { include: { _count: { select: { novels: true } } } },
109
+ },
110
+ }, M));
111
+ expect(novels).toMatchObject([
112
+ { _count: { genres: 1 }, writer: { _count: { novels: 1 } } },
113
+ ]);
114
+ });
115
+ it('counts in one', async () => {
116
+ expect(await once(async () => Novel.count({}, M))).toBe(1);
117
+ });
118
+ it('creates in one, its connections checked', async () => {
119
+ const novel = await once(async () => Novel.create({
120
+ data: {
121
+ title: 'Notes',
122
+ writer: { connect: { id: writer } },
123
+ genres: { connect: [{ id: genre }] },
124
+ },
125
+ include: { writer: true, genres: true },
126
+ }, M));
127
+ expect(novel).toMatchObject({
128
+ title: 'Notes',
129
+ writer: { id: writer },
130
+ genres: [{ id: genre }],
131
+ });
132
+ });
133
+ // A nested create is planned with its parent (D-128).
134
+ it('creates nested rows in one, at any depth', async () => {
135
+ const created = await once(async () => Writer.create({
136
+ data: {
137
+ name: 'Bo',
138
+ novels: {
139
+ create: [
140
+ {
141
+ title: 'Waves',
142
+ genres: {
143
+ connect: [{ id: genre }],
144
+ create: [{ label: 'sea' }],
145
+ },
146
+ },
147
+ ],
148
+ },
149
+ },
150
+ include: { novels: { include: { genres: true } } },
151
+ }, M));
152
+ expect(created).toMatchObject({
153
+ novels: [{ title: 'Waves', genres: [{ id: genre }, { label: 'sea' }] }],
154
+ });
155
+ });
156
+ it('writes nothing when a connection is missing', async () => {
157
+ const created = once(async () => Novel.create({
158
+ data: {
159
+ title: 'Lost',
160
+ writer: { connect: { id: writer } },
161
+ genres: { connect: [{ id: genre }, { id: me }] },
162
+ },
163
+ }, M));
164
+ await expect(created).rejects.toMatchObject({ code: 'NOT_FOUND' });
165
+ expect(await Novel.count({}, M)).toBe(1);
166
+ });
167
+ it('updates by id in one as Master, the row checked', async () => {
168
+ const updated = await once(async () => Writer.update({ where: { id: writer }, data: { name: 'Lovelace' } }, M));
169
+ expect(updated.name).toBe('Lovelace');
170
+ const missing = once(async () => Writer.update({ where: { id: me }, data: { name: 'Nobody' } }, M));
171
+ await expect(missing).rejects.toMatchObject({ code: 'NOT_FOUND' });
172
+ });
173
+ it('moves a many in one, disconnected and connected', async () => {
174
+ const other = (await Genre.create({ data: { label: 'saga' } }, M)).id;
175
+ const moved = await once(async () => Novel.update({
176
+ where: { id: engines },
177
+ data: {
178
+ genres: {
179
+ disconnect: [{ id: genre }],
180
+ connect: [{ id: other }],
181
+ },
182
+ },
183
+ include: { genres: true },
184
+ }, M));
185
+ expect(moved.genres).toMatchObject([{ id: other }]);
186
+ // The same genre on both sides: disconnected, then connected again.
187
+ const kept = await Novel.update({
188
+ where: { id: engines },
189
+ data: {
190
+ genres: { disconnect: [{ id: other }], connect: [{ id: other }] },
191
+ },
192
+ include: { genres: true },
193
+ }, M);
194
+ expect(kept.genres).toMatchObject([{ id: other }]);
195
+ });
196
+ it('deletes in one as Master, what it reaches included', async () => {
197
+ // The writer, its novel and the novel's genres, the writer returned.
198
+ const removed = await once(async () => Writer.delete({ where: { id: writer } }, M));
199
+ expect(removed.name).toBe('Ada');
200
+ expect(await Novel.count({}, M)).toBe(0);
201
+ const missing = once(async () => Writer.delete({ where: { id: writer } }, M));
202
+ await expect(missing).rejects.toMatchObject({ code: 'NOT_FOUND' });
203
+ });
204
+ // D-135: the relations a delete returns cost one read before it.
205
+ it('reads the relations a delete returns once, before it', async () => {
206
+ const where = { id: writer };
207
+ const removed = await trips(4, async () => Writer.delete({ where, include: { novels: true } }, M));
208
+ expect(removed.novels).toMatchObject([{ title: 'Engines' }]);
209
+ const { id } = await Cabinet.create({ data: { label: 'a' } }, M);
210
+ const drawers = { where: { id }, include: { drawers: true } };
211
+ // BEGIN, the row locked, read, its drawers loaded, the write, COMMIT.
212
+ await trips(6, async () => Cabinet.delete(drawers, { as: user }));
213
+ });
214
+ it('deletes many in one as Master; a user locks the rows first', async () => {
215
+ const removed = once(async () => Writer.deleteMany({}, M));
216
+ await expect(removed).resolves.toEqual({ count: 1 });
217
+ await Shelf.create({ data: { label: 'top' } }, M);
218
+ // BEGIN, the locked rows, the write, COMMIT (D-111).
219
+ const locked = trips(4, async () => Shelf.deleteMany({}, { as: user }));
220
+ await expect(locked).resolves.toEqual({ count: 1 });
221
+ });
222
+ // D-135
223
+ it('creates many in one, its connections checked', async () => {
224
+ const two = { data: [byWriter(writer), byWriter(writer)] };
225
+ const many = once(async () => Novel.createMany(two, M));
226
+ await expect(many).resolves.toEqual({ count: 2 });
227
+ const labels = { data: [{ label: 'a' }, { label: 'b' }] };
228
+ const shelves = once(async () => Shelf.createMany(labels, { as: user }));
229
+ await expect(shelves).resolves.toEqual({ count: 2 });
230
+ // BEGIN, the write, a read for each row's hook, COMMIT.
231
+ const texts = { data: [{ text: 'a' }, { text: 'b' }] };
232
+ const notes = trips(5, async () => Note.createMany(texts, M));
233
+ await expect(notes).resolves.toEqual({ count: 2 });
234
+ const missing = { data: [byWriter(writer), byWriter(me)] };
235
+ const refused = once(async () => Novel.createMany(missing, M));
236
+ await expect(refused).rejects.toMatchObject({ code: 'NOT_FOUND' });
237
+ expect(await Novel.count({}, M)).toBe(3);
238
+ });
239
+ it('updates many in one as Master; a user locks the rows first', async () => {
240
+ const renamed = once(async () => Writer.updateMany({ data: { name: 'Bo' } }, M));
241
+ await expect(renamed).resolves.toEqual({ count: 1 });
242
+ await Cabinet.create({ data: { label: 'a' } }, M);
243
+ await Cabinet.create({ data: { label: 'b' } }, M);
244
+ // BEGIN, the locked rows, one write for both, COMMIT (D-133).
245
+ const locked = trips(4, async () => Cabinet.updateMany({ data: { label: 'c' } }, { as: user }));
246
+ await expect(locked).resolves.toEqual({ count: 2 });
247
+ });
248
+ it('locks the row a user updates or deletes', async () => {
249
+ const shelf = await once(async () => Shelf.create({ data: { label: 'top' } }, { as: user }));
250
+ // BEGIN, the locked row, the write, COMMIT (D-104).
251
+ await trips(4, async () => Shelf.update({ where: { id: shelf.id }, data: { label: 'high' } }, { as: user }));
252
+ await trips(4, async () => Shelf.delete({ where: { id: shelf.id } }, { as: user }));
253
+ expect(await Shelf.count({}, M)).toBe(0);
254
+ });
255
+ it('locks the row before a relation write reaches update hooks', async () => {
256
+ const cabinet = await Cabinet.create({ data: { label: 'a' } }, M);
257
+ const drawer = await Drawer.create({ data: { label: 'd' } }, M);
258
+ hooks = 0;
259
+ const updated = await Cabinet.update({
260
+ where: { id: cabinet.id },
261
+ data: { drawers: { connect: [{ id: drawer.id }] } },
262
+ include: { drawers: true },
263
+ }, { as: user });
264
+ expect(updated.drawers).toMatchObject([{ id: drawer.id }]);
265
+ expect(hooks).toBe(1);
266
+ });
267
+ it('runs a write with an after hook in a transaction', async () => {
268
+ // BEGIN, the write, the row the hook gets, the row read again, since
269
+ // the hook may change it, COMMIT.
270
+ const note = await trips(5, async () => Note.create({ data: { text: 'hi' } }, M));
271
+ // BEGIN, the write and its row, the row the hook gets, COMMIT.
272
+ const memo = await trips(4, async () => Memo.create({ data: { text: 'hi' } }, M));
273
+ expect([note.text, memo.text]).toEqual(['hi', 'hi']);
274
+ });
275
+ it('runs every write of transaction() in one', async () => {
276
+ // BEGIN, two writes, COMMIT.
277
+ await trips(4, async () => transaction(async () => {
278
+ await Genre.create({ data: { label: 'myth' } }, M);
279
+ await Genre.create({ data: { label: 'saga' } }, M);
280
+ }));
281
+ expect(await Genre.count({}, M)).toBe(3);
282
+ });
283
+ });
284
+ }
@@ -0,0 +1,3 @@
1
+ import type { Adapter } from 'ultimabase';
2
+ export declare function searches(create: () => Adapter): void;
3
+ //# sourceMappingURL=searches.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"searches.d.ts","sourceRoot":"","sources":["../src/searches.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAY,MAAM,YAAY,CAAA;AA+BnD,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,OAAO,GAAG,IAAI,CA+CpD"}
@@ -0,0 +1,57 @@
1
+ import { defineModel, master } from 'ultimabase';
2
+ import { beforeEach, describe, expect, it } from 'vitest';
3
+ import { started } from './suite.js';
4
+ const M = { as: master };
5
+ const Note = defineModel({
6
+ name: 'Note',
7
+ fields: (f) => ({
8
+ title: f.string({ search: true }),
9
+ body: f.string({ search: true, optional: true }),
10
+ order: f.number({ integer: true }),
11
+ }),
12
+ });
13
+ const notes = [
14
+ { title: 'Red apples', body: 'apple pie and apple jam', order: 1 },
15
+ { title: 'Green pears', body: 'no apple here', order: 2 },
16
+ { title: 'Blue sky', order: 3 },
17
+ { title: 'Apple trees', body: 'orchards', order: 4 },
18
+ ];
19
+ const found = async (search) => {
20
+ const rows = await Note.findMany({ where: { _search: search }, orderBy: { order: 'asc' } }, M);
21
+ return rows.map((row) => row.order);
22
+ };
23
+ export function searches(create) {
24
+ describe('searches', () => {
25
+ started(create, [Note]);
26
+ beforeEach(async () => {
27
+ await Note.createMany({ data: notes }, M);
28
+ });
29
+ it('matches words across the searched fields, whatever their case', async () => {
30
+ expect(await found('APPLE')).toStrictEqual([1, 2, 4]);
31
+ expect(await found('apple orchards')).toStrictEqual([4]);
32
+ expect(await found('sky or pears')).toStrictEqual([2, 3]);
33
+ expect(await found('apple -pears')).toStrictEqual([1, 4]);
34
+ expect(await found('"blue sky"')).toStrictEqual([3]);
35
+ expect(await found('"sky blue"')).toStrictEqual([]);
36
+ expect(await found(' - "" ')).toStrictEqual([]);
37
+ });
38
+ it('ranks the rows by how often the words occur', async () => {
39
+ const rows = await Note.findMany({
40
+ where: { _search: 'apple' },
41
+ orderBy: [{ _relevance: 'desc' }, { order: 'desc' }],
42
+ }, M);
43
+ expect(rows.map((row) => row.order)).toStrictEqual([1, 4, 2]);
44
+ });
45
+ it('ranks an and of words by how near they are, none past 100 words', async () => {
46
+ const far = `${'filler '.repeat(100)}jam`;
47
+ await Note.createMany({
48
+ data: [
49
+ { title: 'Apple far', body: far, order: 6 },
50
+ { title: 'Jam', body: 'one apple', order: 5 },
51
+ ],
52
+ }, M);
53
+ const rows = await Note.findMany({ where: { _search: 'apple jam' }, orderBy: { _relevance: 'desc' } }, M);
54
+ expect(rows.map((row) => row.order)).toStrictEqual([1, 5, 6]);
55
+ });
56
+ });
57
+ }
@@ -0,0 +1,3 @@
1
+ import type { Logger } from 'ultimabase';
2
+ export declare const silent: Logger;
3
+ //# sourceMappingURL=silent.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"silent.d.ts","sourceRoot":"","sources":["../src/silent.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAIxC,eAAO,MAAM,MAAM,EAAE,MAKpB,CAAA"}
package/dist/silent.js ADDED
@@ -0,0 +1,7 @@
1
+ const nothing = () => undefined;
2
+ export const silent = {
3
+ info: nothing,
4
+ warn: nothing,
5
+ error: nothing,
6
+ child: () => silent,
7
+ };
@@ -0,0 +1,3 @@
1
+ import type { Adapter } from 'ultimabase';
2
+ export declare function sorts(create: () => Adapter): void;
3
+ //# sourceMappingURL=sorts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sorts.d.ts","sourceRoot":"","sources":["../src/sorts.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAU,MAAM,YAAY,CAAA;AAiCjD,wBAAgB,KAAK,CAAC,MAAM,EAAE,MAAM,OAAO,GAAG,IAAI,CA0GjD"}
package/dist/sorts.js ADDED
@@ -0,0 +1,108 @@
1
+ import { defineModel, master } from 'ultimabase';
2
+ import { beforeEach, describe, expect, it } from 'vitest';
3
+ import { started } from './suite.js';
4
+ const M = { as: master };
5
+ const Writer = defineModel({
6
+ name: 'Writer',
7
+ fields: (f) => ({
8
+ nick: f.string({ optional: true }),
9
+ notes: f.many(() => Note),
10
+ }),
11
+ });
12
+ const Note = defineModel({
13
+ name: 'Note',
14
+ fields: (f) => ({
15
+ title: f.string({ unique: true }),
16
+ rank: f.number({ optional: true }),
17
+ writer: f.one(() => Writer, { optional: true }),
18
+ }),
19
+ });
20
+ const titles = async (args) => (await Note.findMany({ ...args }, M)).map((note) => note.title);
21
+ const rank = async (orderBy) => titles({ orderBy });
22
+ export function sorts(create) {
23
+ describe('sorts', () => {
24
+ started(create, [Writer, Note]);
25
+ // a and d by x, b by a writer with no nick, c by none.
26
+ beforeEach(async () => {
27
+ const x = await Writer.create({ data: { nick: 'x' } }, M);
28
+ const anon = await Writer.create({ data: {} }, M);
29
+ const notes = [
30
+ { title: 'a', rank: 2, writer: x.id },
31
+ { title: 'b', rank: null, writer: anon.id },
32
+ { title: 'c', rank: 1 },
33
+ { title: 'd', rank: null, writer: x.id },
34
+ ];
35
+ for (const { writer, ...data } of notes) {
36
+ const connected = writer === undefined
37
+ ? data
38
+ : { ...data, writer: { connect: { id: writer } } };
39
+ await Note.create({ data: connected }, M);
40
+ }
41
+ });
42
+ it('puts the nulls first or last', async () => {
43
+ expect(await rank({ rank: { sort: 'asc', nulls: 'first' } })).toEqual([
44
+ 'b',
45
+ 'd',
46
+ 'c',
47
+ 'a',
48
+ ]);
49
+ expect(await rank({ rank: { sort: 'desc', nulls: 'last' } })).toEqual([
50
+ 'a',
51
+ 'c',
52
+ 'b',
53
+ 'd',
54
+ ]);
55
+ expect(await rank({ rank: { sort: 'asc' } })).toEqual([
56
+ 'c',
57
+ 'a',
58
+ 'b',
59
+ 'd',
60
+ ]);
61
+ expect(await rank({ rank: { sort: 'desc', nulls: 'first' } })).toEqual([
62
+ 'b',
63
+ 'd',
64
+ 'a',
65
+ 'c',
66
+ ]);
67
+ // A row the relation does not reach has a null too.
68
+ expect(await rank({ writer: { nick: { sort: 'asc', nulls: 'first' } } })).toEqual(['b', 'c', 'a', 'd']);
69
+ });
70
+ it('pages from a cursor where the nulls sort, forwards and backwards', async () => {
71
+ const last = { rank: { sort: 'desc', nulls: 'last' } };
72
+ const first = { rank: { sort: 'asc', nulls: 'first' } };
73
+ const from = (title, orderBy, take) => titles({ orderBy, cursor: { title }, take });
74
+ expect(await from('c', last, 2)).toEqual(['c', 'b']);
75
+ expect(await from('c', last, -2)).toEqual(['a', 'c']);
76
+ expect(await from('b', last, 2)).toEqual(['b', 'd']);
77
+ expect(await from('b', last, -2)).toEqual(['c', 'b']);
78
+ expect(await from('d', first, 2)).toEqual(['d', 'c']);
79
+ expect(await from('d', first, -2)).toEqual(['b', 'd']);
80
+ expect(await titles({ orderBy: first, take: -3 })).toEqual([
81
+ 'd',
82
+ 'c',
83
+ 'a',
84
+ ]);
85
+ });
86
+ it('keeps the first row of each distinct key where the nulls sort', async () => {
87
+ expect(await titles({
88
+ distinct: ['writer'],
89
+ orderBy: { rank: { sort: 'asc', nulls: 'first' } },
90
+ })).toEqual(['b', 'd', 'c']);
91
+ });
92
+ it('sorts the groups of an optional key with their null', async () => {
93
+ const ranks = async (nulls) => (await Note.groupBy({ by: ['rank'], orderBy: { rank: { sort: 'desc', nulls } } }, M)).map((group) => group.rank);
94
+ expect(await ranks('last')).toEqual([2, 1, null]);
95
+ expect(await ranks('first')).toEqual([null, 2, 1]);
96
+ const writers = await Note.groupBy({
97
+ by: ['writer'],
98
+ _count: true,
99
+ orderBy: [
100
+ { _count: 'desc' },
101
+ { writer: { sort: 'asc', nulls: 'first' } },
102
+ ],
103
+ }, M);
104
+ expect(writers.map((group) => group._count)).toEqual([2, 1, 1]);
105
+ expect(writers[1]?.writer).toBeNull();
106
+ });
107
+ });
108
+ }
@@ -0,0 +1,7 @@
1
+ import type { Adapter } from 'ultimabase';
2
+ import { defineApp } from 'ultimabase';
3
+ type Models = Parameters<typeof defineApp>[0]['models'];
4
+ /** Starts `models` on a new database for each test; reads that database. */
5
+ export declare function started(create: () => Adapter, models: Models): () => Adapter;
6
+ export {};
7
+ //# sourceMappingURL=suite.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"suite.d.ts","sourceRoot":"","sources":["../src/suite.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAA;AACzC,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AAItC,KAAK,MAAM,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAA;AAEvD,4EAA4E;AAC5E,wBAAgB,OAAO,CAAC,MAAM,EAAE,MAAM,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,OAAO,CAU5E"}
package/dist/suite.js ADDED
@@ -0,0 +1,15 @@
1
+ import { defineApp } from 'ultimabase';
2
+ import { afterEach, beforeEach } from 'vitest';
3
+ import { silent } from './silent.js';
4
+ /** Starts `models` on a new database for each test; reads that database. */
5
+ export function started(create, models) {
6
+ let database;
7
+ let app;
8
+ beforeEach(async () => {
9
+ database = create();
10
+ app = defineApp({ database, models, logger: silent });
11
+ await app.start();
12
+ });
13
+ afterEach(async () => app.stop());
14
+ return () => database;
15
+ }
@@ -0,0 +1,11 @@
1
+ import type { Adapter } from 'ultimabase';
2
+ export type Options = {
3
+ /** How many round trips the adapters have made so far, BEGIN and COMMIT included (D-98). */
4
+ readonly roundTrips: () => number;
5
+ };
6
+ /**
7
+ * Runs the contract against the adapters `create` returns, one per test, each
8
+ * on an empty database.
9
+ */
10
+ export declare function testAdapter(name: string, create: () => Adapter, options: Options): void;
11
+ //# sourceMappingURL=test-adapter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"test-adapter.d.ts","sourceRoot":"","sources":["../src/test-adapter.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAA;AA0BzC,MAAM,MAAM,OAAO,GAAG;IACrB,4FAA4F;IAC5F,QAAQ,CAAC,UAAU,EAAE,MAAM,MAAM,CAAA;CACjC,CAAA;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAC1B,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,OAAO,EACrB,OAAO,EAAE,OAAO,GACd,IAAI,CA0BN"}