@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/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # @ultimabase/testing
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies
8
+ - ultimabase@0.1.1
9
+
10
+ ## 0.1.0
11
+
12
+ ### Minor Changes
13
+
14
+ - 0e885c4: The first release: the kernel, its adapters, transports and plugins, the testing helpers and the starter.
15
+
16
+ ### Patch Changes
17
+
18
+ - Updated dependencies [0e885c4]
19
+ - ultimabase@0.1.0
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # @ultimabase/testing
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Test helpers for UltimaBase, with Vitest: an application started before each test on a new in-memory database, callers to run queries as, and the suite a database adapter passes.
4
+
5
+ Documentation: [Testing](https://ultimabase.dev/docs/testing).
@@ -0,0 +1,3 @@
1
+ import type { Adapter } from 'ultimabase';
2
+ export declare function aggregates(create: () => Adapter): void;
3
+ //# sourceMappingURL=aggregates.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"aggregates.d.ts","sourceRoot":"","sources":["../src/aggregates.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAY,MAAM,YAAY,CAAA;AA4BnD,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,OAAO,GAAG,IAAI,CAgGtD"}
@@ -0,0 +1,99 @@
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 Sale = defineModel({
6
+ name: 'Sale',
7
+ fields: (f) => ({
8
+ amount: f.number(),
9
+ units: f.number({ integer: true, optional: true }),
10
+ code: f.string({ optional: true }),
11
+ stage: f.enumOf(['b', 'a', 'c']),
12
+ on: f.dateTime({ optional: true }),
13
+ region: f.string(),
14
+ }),
15
+ });
16
+ /** More aggregates than a database function takes arguments. */
17
+ const names = Array.from({ length: 21 }, (_, at) => `n${at}`);
18
+ const Wide = defineModel({
19
+ name: 'Wide',
20
+ fields: (f) => Object.fromEntries(names.map((name) => [name, f.number()])),
21
+ });
22
+ const day = (n) => new Date(Date.UTC(2026, 4, n, 12, 30));
23
+ export function aggregates(create) {
24
+ describe('aggregates', () => {
25
+ started(create, [Sale, Wide]);
26
+ beforeEach(async () => {
27
+ await Sale.createMany({
28
+ data: [
29
+ {
30
+ amount: 1.5,
31
+ units: 2,
32
+ code: 'a',
33
+ stage: 'b',
34
+ on: day(3),
35
+ region: 'n',
36
+ },
37
+ {
38
+ amount: 0.25,
39
+ units: 5,
40
+ code: 'Z',
41
+ stage: 'c',
42
+ on: day(1),
43
+ region: 'n',
44
+ },
45
+ { amount: -4, code: 'é', stage: 'a', region: 'n' },
46
+ { amount: 100, units: 7, stage: 'c', on: day(9), region: 's' },
47
+ ],
48
+ }, M);
49
+ });
50
+ it('aggregates the rows `where` selects, skipping nulls', async () => {
51
+ expect(await Sale.aggregate({
52
+ where: { region: 'n' },
53
+ _count: { _all: true, units: true, on: true },
54
+ _sum: { amount: true, units: true },
55
+ _avg: { amount: true, units: true },
56
+ _min: { amount: true, code: true, on: true, stage: true },
57
+ _max: { units: true, code: true, on: true, stage: true },
58
+ }, M)).toStrictEqual({
59
+ _count: { _all: 3, units: 2, on: 2 },
60
+ _sum: { amount: -2.25, units: 7 },
61
+ _avg: { amount: -0.75, units: 3.5 },
62
+ _min: { amount: -4, code: 'Z', on: day(1), stage: 'a' },
63
+ _max: { units: 5, code: 'é', on: day(3), stage: 'c' },
64
+ });
65
+ });
66
+ it('counts rows and gives null without a value', async () => {
67
+ expect(await Sale.aggregate({ _count: true }, M)).toStrictEqual({
68
+ _count: 4,
69
+ });
70
+ expect(await Sale.aggregate({
71
+ where: { region: 'w' },
72
+ _count: true,
73
+ _sum: { amount: true },
74
+ _avg: { units: true },
75
+ _min: { on: true },
76
+ _max: { code: true },
77
+ }, M)).toStrictEqual({
78
+ _count: 0,
79
+ _sum: { amount: null },
80
+ _avg: { units: null },
81
+ _min: { on: null },
82
+ _max: { code: null },
83
+ });
84
+ });
85
+ it('takes more aggregates than a function takes arguments', async () => {
86
+ const all = Object.fromEntries(names.map((name) => [name, true]));
87
+ const row = Object.fromEntries(names.map((name, at) => [name, at]));
88
+ await Wide.create({ data: row }, M);
89
+ const result = await Wide.aggregate({ _count: all, _sum: all, _avg: all, _min: all, _max: all }, M);
90
+ expect(result).toStrictEqual({
91
+ _count: Object.fromEntries(names.map((name) => [name, 1])),
92
+ _sum: row,
93
+ _avg: row,
94
+ _min: row,
95
+ _max: row,
96
+ });
97
+ });
98
+ });
99
+ }
@@ -0,0 +1,13 @@
1
+ import type { Identity } from 'ultimabase';
2
+ type UserIdentity = Extract<Identity, {
3
+ readonly kind: 'user';
4
+ }>;
5
+ /** Acts as `user`, the registered user model's record, or `{ id }` without one. */
6
+ export declare function asUser(user: UserIdentity['user']): Identity;
7
+ /**
8
+ * A user caller of id `id` for the contract suites, whatever user model an
9
+ * application registers.
10
+ */
11
+ export declare function suiteUser(id: string): Identity;
12
+ export {};
13
+ //# sourceMappingURL=as-user.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"as-user.d.ts","sourceRoot":"","sources":["../src/as-user.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA;AAE1C,KAAK,YAAY,GAAG,OAAO,CAAC,QAAQ,EAAE;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAAA;AAEhE,mFAAmF;AACnF,wBAAgB,MAAM,CAAC,IAAI,EAAE,YAAY,CAAC,MAAM,CAAC,GAAG,QAAQ,CAG3D;AAMD;;;GAGG;AACH,wBAAgB,SAAS,CAAC,EAAE,EAAE,MAAM,GAAG,QAAQ,CAI9C"}
@@ -0,0 +1,17 @@
1
+ /** Acts as `user`, the registered user model's record, or `{ id }` without one. */
2
+ export function asUser(user) {
3
+ const caller = Object.freeze({ kind: 'user', user });
4
+ return caller;
5
+ }
6
+ /** A user record has an `id`, which is all the contract suites' models read. */
7
+ const isUserRecord = (value) => 'id' in value;
8
+ /**
9
+ * A user caller of id `id` for the contract suites, whatever user model an
10
+ * application registers.
11
+ */
12
+ export function suiteUser(id) {
13
+ const user = { id };
14
+ if (!isUserRecord(user))
15
+ throw new TypeError('A user record has an id.');
16
+ return asUser(user);
17
+ }
@@ -0,0 +1,3 @@
1
+ import type { Adapter } from 'ultimabase';
2
+ export declare function bulkReturns(create: () => Adapter): void;
3
+ //# sourceMappingURL=bulk-returns.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bulk-returns.d.ts","sourceRoot":"","sources":["../src/bulk-returns.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAA;AAkDzC,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,OAAO,GAAG,IAAI,CAiGvD"}
@@ -0,0 +1,125 @@
1
+ import { defineModel, master } from 'ultimabase';
2
+ import { 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 user = { as: suiteUser('00000000-0000-4000-8000-000000000000') };
7
+ const Board = defineModel({
8
+ name: 'Board',
9
+ fields: (f) => ({
10
+ name: f.string(),
11
+ tickets: f.many(() => Ticket),
12
+ notes: f.many(() => Note),
13
+ }),
14
+ }).with({ access: { read: () => true } });
15
+ /** No rule, no hook: Master's update of them is one UPDATE (D-133). */
16
+ const Ticket = defineModel({
17
+ name: 'Ticket',
18
+ fields: (f) => ({
19
+ title: f.string(),
20
+ points: f.number({ integer: true }),
21
+ board: f.one(() => Board, { optional: true }),
22
+ }),
23
+ });
24
+ /** A user reads the notes that are not hidden; a hook rewrites `bump`. */
25
+ const Note = defineModel({
26
+ name: 'Note',
27
+ fields: (f) => ({
28
+ text: f.string(),
29
+ hidden: f.boolean({ default: false }),
30
+ board: f.one(() => Board, { optional: true }),
31
+ }),
32
+ }).with({
33
+ access: {
34
+ read: ({ where }) => where({ hidden: false }),
35
+ create: () => true,
36
+ update: () => true,
37
+ },
38
+ hooks: {
39
+ afterCreate: async ({ record }) => {
40
+ if (record.text !== 'bump')
41
+ return;
42
+ await Note.update({ where: { id: record.id }, data: { text: 'up' } }, M);
43
+ },
44
+ },
45
+ });
46
+ export function bulkReturns(create) {
47
+ describe('bulk returns', () => {
48
+ started(create, [Board, Ticket, Note]);
49
+ it('returns the rows created, in the order of data', async () => {
50
+ const board = await Board.create({ data: { name: 'b' } }, M);
51
+ const connect = { connect: { id: board.id } };
52
+ const rows = await Ticket.createManyAndReturn({
53
+ data: [
54
+ { title: 'z', points: 1, board: connect },
55
+ { title: 'a', points: 2 },
56
+ ],
57
+ select: { title: true, board: { select: { name: true } } },
58
+ }, M);
59
+ expect(rows).toStrictEqual([
60
+ { title: 'z', board: { name: 'b' } },
61
+ { title: 'a', board: null },
62
+ ]);
63
+ const notes = await Note.createManyAndReturn({
64
+ data: [{ text: 'bump', hidden: true }, { text: 'n' }],
65
+ select: { text: true, hidden: true },
66
+ }, user);
67
+ expect(notes).toStrictEqual([
68
+ { text: 'up', hidden: true },
69
+ { text: 'n', hidden: false },
70
+ ]);
71
+ });
72
+ it('returns the rows of one UPDATE in id order, as it left them', async () => {
73
+ const board = await Board.create({ data: { name: 'b' } }, M);
74
+ const connect = { connect: { id: board.id } };
75
+ for (const [title, points] of [
76
+ ['c', 1],
77
+ ['a', 2],
78
+ ['b', 3],
79
+ ]) {
80
+ await Ticket.create({ data: { title, points, board: connect } }, M);
81
+ }
82
+ const where = { title: { in: ['c', 'a'] } };
83
+ const rows = await Ticket.updateManyAndReturn({
84
+ where,
85
+ data: { points: { multiply: 10 } },
86
+ select: { title: true, points: true },
87
+ }, M);
88
+ expect(rows).toStrictEqual([
89
+ { title: 'c', points: 10 },
90
+ { title: 'a', points: 20 },
91
+ ]);
92
+ const shaped = await Ticket.updateManyAndReturn({
93
+ where,
94
+ data: { points: { increment: 1 } },
95
+ select: { points: true, board: { select: { _count: true } } },
96
+ }, M);
97
+ expect(shaped).toStrictEqual([
98
+ { points: 11, board: { _count: { tickets: 3, notes: 0 } } },
99
+ { points: 21, board: { _count: { tickets: 3, notes: 0 } } },
100
+ ]);
101
+ });
102
+ it('returns each row its rule lets it update, read or not', async () => {
103
+ const board = await Board.create({ data: { name: 'b' } }, M);
104
+ const connect = { connect: { id: board.id } };
105
+ for (const text of ['q', 'p', 'r']) {
106
+ await Note.create({ data: { text, board: connect } }, M);
107
+ }
108
+ const rows = await Note.updateManyAndReturn({
109
+ where: { text: { not: 'r' } },
110
+ data: { hidden: true },
111
+ select: {
112
+ text: true,
113
+ hidden: true,
114
+ board: { select: { name: true } },
115
+ },
116
+ }, user);
117
+ expect(rows).toStrictEqual([
118
+ { text: 'q', hidden: true, board: { name: 'b' } },
119
+ { text: 'p', hidden: true, board: { name: 'b' } },
120
+ ]);
121
+ const none = { where: { text: 'q' }, data: { text: 'x' } };
122
+ expect(await Note.updateManyAndReturn(none, user)).toEqual([]);
123
+ });
124
+ });
125
+ }
@@ -0,0 +1,3 @@
1
+ import type { Adapter } from 'ultimabase';
2
+ export declare function cascades(create: () => Adapter): void;
3
+ //# sourceMappingURL=cascades.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cascades.d.ts","sourceRoot":"","sources":["../src/cascades.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAA;AAsIzC,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,OAAO,GAAG,IAAI,CAyMpD"}
@@ -0,0 +1,269 @@
1
+ import { defineModel, master } from 'ultimabase';
2
+ import { 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 open = () => true;
7
+ const Shop = defineModel({
8
+ name: 'Shop',
9
+ fields: (f) => ({
10
+ name: f.string({ unique: true }),
11
+ aisles: f.many(() => Aisle),
12
+ items: f.many(() => Item),
13
+ clerks: f.many(() => Clerk),
14
+ orders: f.many(() => Order),
15
+ }),
16
+ }).with({ access: { read: open, delete: open } });
17
+ const Aisle = defineModel({
18
+ name: 'Aisle',
19
+ fields: (f) => ({
20
+ label: f.string(),
21
+ shop: f.one(() => Shop, { onDelete: 'cascade' }),
22
+ items: f.many(() => Item),
23
+ }),
24
+ });
25
+ /** Reached twice from a shop: through its aisle, and directly. */
26
+ const Item = defineModel({
27
+ name: 'Item',
28
+ fields: (f) => ({
29
+ label: f.string(),
30
+ aisle: f.one(() => Aisle, { onDelete: 'cascade' }),
31
+ shop: f.one(() => Shop, { onDelete: 'cascade' }),
32
+ orders: f.many(() => Order),
33
+ }),
34
+ });
35
+ /** An optional relation sets null by default. */
36
+ const Clerk = defineModel({
37
+ name: 'Clerk',
38
+ fields: (f) => ({
39
+ name: f.string(),
40
+ shop: f.one(() => Shop, { optional: true }),
41
+ }),
42
+ });
43
+ /** A required relation restricts by default. */
44
+ const Order = defineModel({
45
+ name: 'Order',
46
+ fields: (f) => ({
47
+ ref: f.string(),
48
+ item: f.one(() => Item),
49
+ shop: f.one(() => Shop, { onDelete: 'cascade' }),
50
+ }),
51
+ });
52
+ /** A cascade that comes back to its own model. */
53
+ const Folder = defineModel({
54
+ name: 'Folder',
55
+ fields: (f) => ({
56
+ name: f.string(),
57
+ parent: f.one(() => Folder, {
58
+ optional: true,
59
+ onDelete: 'cascade',
60
+ inverse: 'children',
61
+ }),
62
+ children: f.many(() => Folder),
63
+ }),
64
+ });
65
+ /** A model a delete both deletes and empties: its rows are loaded (D-102). */
66
+ const Room = defineModel({
67
+ name: 'Room',
68
+ fields: (f) => ({ seats: f.many(() => Seat), spares: f.many(() => Seat) }),
69
+ });
70
+ const Seat = defineModel({
71
+ name: 'Seat',
72
+ fields: (f) => ({
73
+ name: f.string(),
74
+ room: f.one(() => Room, { onDelete: 'cascade', inverse: 'seats' }),
75
+ spare: f.one(() => Room, { optional: true, inverse: 'spares' }),
76
+ }),
77
+ });
78
+ /** A model a delete empties twice: its rows are loaded (D-102). */
79
+ const Wall = defineModel({
80
+ name: 'Wall',
81
+ fields: (f) => ({
82
+ fronts: f.many(() => Poster),
83
+ backs: f.many(() => Poster),
84
+ }),
85
+ });
86
+ const Poster = defineModel({
87
+ name: 'Poster',
88
+ fields: (f) => ({
89
+ front: f.one(() => Wall, { optional: true, inverse: 'fronts' }),
90
+ back: f.one(() => Wall, { optional: true, inverse: 'backs' }),
91
+ }),
92
+ });
93
+ const by = (id) => ({ connect: { id } });
94
+ const user = { as: suiteUser('00000000-0000-4000-8000-000000000000') };
95
+ /** A shop with an aisle, an item in it and a clerk. */
96
+ const stocked = async (name) => {
97
+ const shop = await Shop.create({ data: { name } }, M);
98
+ const aisle = await Aisle.create({ data: { label: `${name} aisle`, shop: by(shop.id) } }, M);
99
+ const item = await Item.create({
100
+ data: { label: `${name} item`, aisle: by(aisle.id), shop: by(shop.id) },
101
+ }, M);
102
+ const clerk = await Clerk.create({ data: { name: `${name} clerk`, shop: by(shop.id) } }, M);
103
+ return { shop, item, clerk };
104
+ };
105
+ const order = async (item, shop) => Order.create({ data: { ref: 'o', item: by(item), shop: by(shop) } }, M);
106
+ const counts = async () => [
107
+ await Shop.count({}, M),
108
+ await Aisle.count({}, M),
109
+ await Item.count({}, M),
110
+ await Order.count({}, M),
111
+ ];
112
+ export function cascades(create) {
113
+ describe('cascades', () => {
114
+ started(create, [
115
+ Shop,
116
+ Aisle,
117
+ Item,
118
+ Clerk,
119
+ Order,
120
+ Folder,
121
+ Room,
122
+ Seat,
123
+ Wall,
124
+ Poster,
125
+ ]);
126
+ it('deletes what a delete reaches, by every path, and returns the row as it was', async () => {
127
+ const reds = await stocked('reds');
128
+ const blues = await stocked('blues');
129
+ const removed = await Shop.delete({ where: { name: 'reds' } }, M);
130
+ expect(removed).toEqual(reds.shop);
131
+ expect(await counts()).toEqual([1, 1, 1, 0]);
132
+ const clerk = await Clerk.findUnique({ where: { id: reds.clerk.id }, include: { shop: true } }, M);
133
+ expect(clerk?.shop).toBeNull();
134
+ expect(clerk?.updatedAt.getTime()).toBeGreaterThanOrEqual(reds.clerk.updatedAt.getTime());
135
+ const kept = await Clerk.findUnique({ where: { id: blues.clerk.id } }, M);
136
+ expect(kept).toEqual(blues.clerk);
137
+ });
138
+ // D-135
139
+ it('returns the relations and counts of a delete as the caller read them before', async () => {
140
+ const reds = await stocked('reds');
141
+ const removed = await Shop.delete({
142
+ where: { id: reds.shop.id },
143
+ include: {
144
+ aisles: true,
145
+ clerks: { include: { shop: true } },
146
+ _count: true,
147
+ },
148
+ }, M);
149
+ expect(removed).toMatchObject({
150
+ name: 'reds',
151
+ aisles: [{ label: 'reds aisle' }],
152
+ clerks: [{ name: 'reds clerk', shop: { name: 'reds' } }],
153
+ _count: { aisles: 1, items: 1, clerks: 1, orders: 0 },
154
+ });
155
+ expect(await counts()).toEqual([0, 0, 0, 0]);
156
+ const blues = await stocked('blues');
157
+ // A user reads no item: the model gives them no read rule.
158
+ const theirs = await Shop.delete({
159
+ where: { id: blues.shop.id },
160
+ select: { name: true, items: true, _count: true },
161
+ }, user);
162
+ expect(theirs).toEqual({
163
+ name: 'blues',
164
+ items: [],
165
+ _count: { aisles: 0, items: 0, clerks: 0, orders: 0 },
166
+ });
167
+ expect(await counts()).toEqual([0, 0, 0, 0]);
168
+ });
169
+ it('refuses a delete a restricting row stops, and writes nothing', async () => {
170
+ const reds = await stocked('reds');
171
+ const blues = await stocked('blues');
172
+ await order(reds.item.id, blues.shop.id);
173
+ for (const caller of [M, user]) {
174
+ await expect(Shop.delete({ where: { id: reds.shop.id } }, caller)).rejects.toMatchObject({
175
+ code: 'RELATION_RESTRICTED',
176
+ details: { model: 'Order', field: 'item' },
177
+ });
178
+ }
179
+ expect(await counts()).toEqual([2, 2, 2, 1]);
180
+ const clerk = await Clerk.findUnique({ where: { id: reds.clerk.id } }, M);
181
+ expect(clerk).toEqual(reds.clerk);
182
+ });
183
+ it('lets go a restricting row the cascade removes', async () => {
184
+ const reds = await stocked('reds');
185
+ await order(reds.item.id, reds.shop.id);
186
+ await Shop.delete({ where: { id: reds.shop.id } }, user);
187
+ expect(await counts()).toEqual([0, 0, 0, 0]);
188
+ });
189
+ it('refuses a missing row', async () => {
190
+ await expect(Shop.delete({ where: { name: 'nowhere' } }, M)).rejects.toMatchObject({ code: 'NOT_FOUND' });
191
+ });
192
+ it('deletes many rows and what they reach, as Master and as a user', async () => {
193
+ const reds = await stocked('reds');
194
+ await stocked('blues');
195
+ await stocked('greens');
196
+ const names = { name: { in: ['reds', 'blues'] } };
197
+ const removed = await Shop.deleteMany({ where: names }, M);
198
+ expect(removed).toEqual({ count: 2 });
199
+ expect(await counts()).toEqual([1, 1, 1, 0]);
200
+ const clerk = await Clerk.findUnique({ where: { id: reds.clerk.id }, include: { shop: true } }, M);
201
+ expect(clerk?.shop).toBeNull();
202
+ expect(await Shop.deleteMany({ where: names }, M)).toEqual({ count: 0 });
203
+ expect(await Shop.deleteMany({}, user)).toEqual({ count: 1 });
204
+ expect(await counts()).toEqual([0, 0, 0, 0]);
205
+ });
206
+ it('deletes many rows chosen by the rows their delete reaches', async () => {
207
+ await stocked('reds');
208
+ await stocked('blues');
209
+ await stocked('greens');
210
+ // Through a cascade, then through a `setNull`.
211
+ const aisle = { aisles: { some: { label: 'reds aisle' } } };
212
+ expect(await Shop.deleteMany({ where: aisle }, M)).toEqual({ count: 1 });
213
+ const clerk = { clerks: { some: { name: 'blues clerk' } } };
214
+ expect(await Shop.deleteMany({ where: clerk }, M)).toEqual({ count: 1 });
215
+ const left = await Shop.findMany({}, M);
216
+ expect(left.map((shop) => shop.name)).toEqual(['greens']);
217
+ expect(await counts()).toEqual([1, 1, 1, 0]);
218
+ });
219
+ it('deletes no row of many when a restricting row stops one', async () => {
220
+ const reds = await stocked('reds');
221
+ const blues = await stocked('blues');
222
+ await order(reds.item.id, blues.shop.id);
223
+ for (const caller of [M, user]) {
224
+ await expect(Shop.deleteMany({ where: { name: 'reds' } }, caller)).rejects.toMatchObject({
225
+ code: 'RELATION_RESTRICTED',
226
+ details: { model: 'Order', field: 'item' },
227
+ });
228
+ }
229
+ expect(await counts()).toEqual([2, 2, 2, 1]);
230
+ });
231
+ it('deletes many trees of one model', async () => {
232
+ const root = await Folder.create({ data: { name: 'root' } }, M);
233
+ await Folder.create({ data: { name: 'child', parent: by(root.id) } }, M);
234
+ await Folder.create({ data: { name: 'other' } }, M);
235
+ await Folder.create({ data: { name: 'kept' } }, M);
236
+ const roots = { name: { in: ['root', 'other'] } };
237
+ expect(await Folder.deleteMany({ where: roots }, M)).toEqual({
238
+ count: 2,
239
+ });
240
+ const left = await Folder.findMany({}, M);
241
+ expect(left.map((folder) => folder.name)).toEqual(['kept']);
242
+ });
243
+ it('follows a cascade back to its own model', async () => {
244
+ const root = await Folder.create({ data: { name: 'root' } }, M);
245
+ const child = await Folder.create({ data: { name: 'child', parent: by(root.id) } }, M);
246
+ await Folder.create({ data: { name: 'leaf', parent: by(child.id) } }, M);
247
+ await Folder.create({ data: { name: 'other' } }, M);
248
+ await Folder.delete({ where: { id: root.id } }, M);
249
+ const left = await Folder.findMany({}, M);
250
+ expect(left.map((folder) => folder.name)).toEqual(['other']);
251
+ });
252
+ it('empties the key of a row it leaves, of a model it deletes', async () => {
253
+ const hall = await Room.create({ data: {} }, M);
254
+ const lobby = await Room.create({ data: {} }, M);
255
+ await Seat.create({ data: { name: 'in', room: by(hall.id) } }, M);
256
+ await Seat.create({ data: { name: 'out', room: by(lobby.id), spare: by(hall.id) } }, M);
257
+ await Room.delete({ where: { id: hall.id } }, M);
258
+ const left = await Seat.findMany({ include: { spare: true } }, M);
259
+ expect(left).toMatchObject([{ name: 'out', spare: null }]);
260
+ });
261
+ it('empties two keys of a row that names the deleted row twice', async () => {
262
+ const wall = await Wall.create({ data: {} }, M);
263
+ const poster = await Poster.create({ data: { front: by(wall.id), back: by(wall.id) } }, M);
264
+ await Wall.delete({ where: { id: wall.id } }, M);
265
+ const left = await Poster.findUnique({ where: { id: poster.id }, include: { front: true, back: true } }, M);
266
+ expect(left).toMatchObject({ front: null, back: null });
267
+ });
268
+ });
269
+ }
@@ -0,0 +1,3 @@
1
+ import type { Adapter } from 'ultimabase';
2
+ export declare function cases(create: () => Adapter): void;
3
+ //# sourceMappingURL=cases.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cases.d.ts","sourceRoot":"","sources":["../src/cases.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAqB,MAAM,YAAY,CAAA;AAiB5D,wBAAgB,KAAK,CAAC,MAAM,EAAE,MAAM,OAAO,GAAG,IAAI,CAwDjD"}
package/dist/cases.js ADDED
@@ -0,0 +1,60 @@
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 Title = defineModel({
6
+ name: 'Title',
7
+ fields: (f) => ({ text: f.string({ optional: true }) }),
8
+ });
9
+ const texts = async (where) => (await Title.findMany({ where, orderBy: { text: 'asc' } }, M)).map((title) => title.text);
10
+ export function cases(create) {
11
+ describe('case-insensitive filters', () => {
12
+ started(create, [Title]);
13
+ beforeEach(async () => {
14
+ for (const text of ['Analysis', 'Compilers', 'Engines', 'Émile', null]) {
15
+ await Title.create({ data: { text } }, M);
16
+ }
17
+ });
18
+ it('ignores case with every string operator', async () => {
19
+ const mode = 'insensitive';
20
+ expect(await texts({ text: { equals: 'analysis', mode } })).toEqual([
21
+ 'Analysis',
22
+ ]);
23
+ expect(await texts({ text: { not: 'ANALYSIS', mode } })).toEqual([
24
+ 'Compilers',
25
+ 'Engines',
26
+ 'Émile',
27
+ ]);
28
+ expect(await texts({ text: { in: ['ENGINES', 'nope'], mode } })).toEqual([
29
+ 'Engines',
30
+ ]);
31
+ expect(await texts({ text: { notIn: ['engines', 'émile'], mode } })).toEqual(['Analysis', 'Compilers']);
32
+ expect(await texts({ text: { contains: 'GIN', mode } })).toEqual([
33
+ 'Engines',
34
+ ]);
35
+ expect(await texts({ text: { startsWith: 'c', mode } })).toEqual([
36
+ 'Compilers',
37
+ ]);
38
+ expect(await texts({ text: { endsWith: 'SIS', mode } })).toEqual([
39
+ 'Analysis',
40
+ ]);
41
+ });
42
+ it('folds letters beyond ASCII', async () => {
43
+ const mode = 'insensitive';
44
+ expect(await texts({ text: { startsWith: 'é', mode } })).toEqual([
45
+ 'Émile',
46
+ ]);
47
+ expect(await texts({ text: { equals: 'ÉMILE', mode } })).toEqual([
48
+ 'Émile',
49
+ ]);
50
+ });
51
+ it('keeps pattern characters as they are', async () => {
52
+ const mode = 'insensitive';
53
+ expect(await texts({ text: { equals: 'analysi_', mode } })).toEqual([]);
54
+ expect(await texts({ text: { contains: '%', mode } })).toEqual([]);
55
+ expect(await texts({ text: { in: [], mode } })).toEqual([]);
56
+ // `mode` alone filters nothing, as in Prisma.
57
+ expect(await texts({ text: { mode } })).toHaveLength(5);
58
+ });
59
+ });
60
+ }
@@ -0,0 +1,3 @@
1
+ import type { Adapter } from 'ultimabase';
2
+ export declare function counts(create: () => Adapter): void;
3
+ //# sourceMappingURL=counts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"counts.d.ts","sourceRoot":"","sources":["../src/counts.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAY,MAAM,YAAY,CAAA;AAmEnD,wBAAgB,MAAM,CAAC,MAAM,EAAE,MAAM,OAAO,GAAG,IAAI,CAwJlD"}