@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,128 @@
1
+ // Aggregates in the contract (D-143): a `null` is skipped, no value gives
2
+ // `null`, strings compare by code point, dates come back as dates. The
3
+ // numbers add up exactly, so both adapters give the same sums.
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 Sale = defineModel({
12
+ name: 'Sale',
13
+ fields: (f) => ({
14
+ amount: f.number(),
15
+ units: f.number({ integer: true, optional: true }),
16
+ code: f.string({ optional: true }),
17
+ stage: f.enumOf(['b', 'a', 'c']),
18
+ on: f.dateTime({ optional: true }),
19
+ region: f.string(),
20
+ }),
21
+ })
22
+
23
+ /** More aggregates than a database function takes arguments. */
24
+ const names = Array.from({ length: 21 }, (_, at) => `n${at}`)
25
+ const Wide = defineModel({
26
+ name: 'Wide',
27
+ fields: (f) => Object.fromEntries(names.map((name) => [name, f.number()])),
28
+ })
29
+
30
+ const day = (n: number): Date => new Date(Date.UTC(2026, 4, n, 12, 30))
31
+
32
+ export function aggregates(create: () => Adapter): void {
33
+ describe('aggregates', () => {
34
+ started(create, [Sale, Wide])
35
+
36
+ beforeEach(async () => {
37
+ await Sale.createMany(
38
+ {
39
+ data: [
40
+ {
41
+ amount: 1.5,
42
+ units: 2,
43
+ code: 'a',
44
+ stage: 'b',
45
+ on: day(3),
46
+ region: 'n',
47
+ },
48
+ {
49
+ amount: 0.25,
50
+ units: 5,
51
+ code: 'Z',
52
+ stage: 'c',
53
+ on: day(1),
54
+ region: 'n',
55
+ },
56
+ { amount: -4, code: 'é', stage: 'a', region: 'n' },
57
+ { amount: 100, units: 7, stage: 'c', on: day(9), region: 's' },
58
+ ],
59
+ },
60
+ M,
61
+ )
62
+ })
63
+
64
+ it('aggregates the rows `where` selects, skipping nulls', async () => {
65
+ expect(
66
+ await Sale.aggregate(
67
+ {
68
+ where: { region: 'n' },
69
+ _count: { _all: true, units: true, on: true },
70
+ _sum: { amount: true, units: true },
71
+ _avg: { amount: true, units: true },
72
+ _min: { amount: true, code: true, on: true, stage: true },
73
+ _max: { units: true, code: true, on: true, stage: true },
74
+ },
75
+ M,
76
+ ),
77
+ ).toStrictEqual({
78
+ _count: { _all: 3, units: 2, on: 2 },
79
+ _sum: { amount: -2.25, units: 7 },
80
+ _avg: { amount: -0.75, units: 3.5 },
81
+ _min: { amount: -4, code: 'Z', on: day(1), stage: 'a' },
82
+ _max: { units: 5, code: 'é', on: day(3), stage: 'c' },
83
+ })
84
+ })
85
+
86
+ it('counts rows and gives null without a value', async () => {
87
+ expect(await Sale.aggregate({ _count: true }, M)).toStrictEqual({
88
+ _count: 4,
89
+ })
90
+ expect(
91
+ await Sale.aggregate(
92
+ {
93
+ where: { region: 'w' },
94
+ _count: true,
95
+ _sum: { amount: true },
96
+ _avg: { units: true },
97
+ _min: { on: true },
98
+ _max: { code: true },
99
+ },
100
+ M,
101
+ ),
102
+ ).toStrictEqual({
103
+ _count: 0,
104
+ _sum: { amount: null },
105
+ _avg: { units: null },
106
+ _min: { on: null },
107
+ _max: { code: null },
108
+ })
109
+ })
110
+
111
+ it('takes more aggregates than a function takes arguments', async () => {
112
+ const all = Object.fromEntries(names.map((name) => [name, true] as const))
113
+ const row = Object.fromEntries(names.map((name, at) => [name, at]))
114
+ await Wide.create({ data: row }, M)
115
+ const result = await Wide.aggregate(
116
+ { _count: all, _sum: all, _avg: all, _min: all, _max: all },
117
+ M,
118
+ )
119
+ expect(result).toStrictEqual({
120
+ _count: Object.fromEntries(names.map((name) => [name, 1])),
121
+ _sum: row,
122
+ _avg: row,
123
+ _min: row,
124
+ _max: row,
125
+ })
126
+ })
127
+ })
128
+ }
package/src/as-user.ts ADDED
@@ -0,0 +1,26 @@
1
+ // The caller of a signed-in user, for a test's `{ as }` (D-80, D-97); the
2
+ // kernel completes it with its plugins' keys (D-121). In an application a
3
+ // user caller comes from a session (D-39).
4
+ import type { Identity } from 'ultimabase'
5
+
6
+ type UserIdentity = Extract<Identity, { readonly kind: 'user' }>
7
+
8
+ /** Acts as `user`, the registered user model's record, or `{ id }` without one. */
9
+ export function asUser(user: UserIdentity['user']): Identity {
10
+ const caller: UserIdentity = Object.freeze({ kind: 'user', user })
11
+ return caller
12
+ }
13
+
14
+ /** A user record has an `id`, which is all the contract suites' models read. */
15
+ const isUserRecord = (value: object): value is UserIdentity['user'] =>
16
+ 'id' in value
17
+
18
+ /**
19
+ * A user caller of id `id` for the contract suites, whatever user model an
20
+ * application registers.
21
+ */
22
+ export function suiteUser(id: string): Identity {
23
+ const user = { id }
24
+ if (!isUserRecord(user)) throw new TypeError('A user record has an id.')
25
+ return asUser(user)
26
+ }
@@ -0,0 +1,151 @@
1
+ // The rows a bulk write returns (D-149): `createManyAndReturn` in the order
2
+ // of `data`, `updateManyAndReturn` in `id` order, each as the write left it,
3
+ // read in the write or, after a hook, once it ran.
4
+ import type { Adapter } from 'ultimabase'
5
+ import { defineModel, master } from 'ultimabase'
6
+ import { describe, expect, it } from 'vitest'
7
+ import { suiteUser } from './as-user.ts'
8
+ import { started } from './suite.ts'
9
+
10
+ const M = { as: master }
11
+ const user = { as: suiteUser('00000000-0000-4000-8000-000000000000') }
12
+
13
+ const Board = defineModel({
14
+ name: 'Board',
15
+ fields: (f) => ({
16
+ name: f.string(),
17
+ tickets: f.many(() => Ticket),
18
+ notes: f.many(() => Note),
19
+ }),
20
+ }).with({ access: { read: () => true } })
21
+
22
+ /** No rule, no hook: Master's update of them is one UPDATE (D-133). */
23
+ const Ticket = defineModel({
24
+ name: 'Ticket',
25
+ fields: (f) => ({
26
+ title: f.string(),
27
+ points: f.number({ integer: true }),
28
+ board: f.one(() => Board, { optional: true }),
29
+ }),
30
+ })
31
+
32
+ /** A user reads the notes that are not hidden; a hook rewrites `bump`. */
33
+ const Note = defineModel({
34
+ name: 'Note',
35
+ fields: (f) => ({
36
+ text: f.string(),
37
+ hidden: f.boolean({ default: false }),
38
+ board: f.one(() => Board, { optional: true }),
39
+ }),
40
+ }).with({
41
+ access: {
42
+ read: ({ where }) => where({ hidden: false }),
43
+ create: () => true,
44
+ update: () => true,
45
+ },
46
+ hooks: {
47
+ afterCreate: async ({ record }) => {
48
+ if (record.text !== 'bump') return
49
+ await Note.update({ where: { id: record.id }, data: { text: 'up' } }, M)
50
+ },
51
+ },
52
+ })
53
+
54
+ export function bulkReturns(create: () => Adapter): void {
55
+ describe('bulk returns', () => {
56
+ started(create, [Board, Ticket, Note])
57
+
58
+ it('returns the rows created, in the order of data', async () => {
59
+ const board = await Board.create({ data: { name: 'b' } }, M)
60
+ const connect = { connect: { id: board.id } }
61
+ const rows = await Ticket.createManyAndReturn(
62
+ {
63
+ data: [
64
+ { title: 'z', points: 1, board: connect },
65
+ { title: 'a', points: 2 },
66
+ ],
67
+ select: { title: true, board: { select: { name: true } } },
68
+ },
69
+ M,
70
+ )
71
+ expect(rows).toStrictEqual([
72
+ { title: 'z', board: { name: 'b' } },
73
+ { title: 'a', board: null },
74
+ ])
75
+ const notes = await Note.createManyAndReturn(
76
+ {
77
+ data: [{ text: 'bump', hidden: true }, { text: 'n' }],
78
+ select: { text: true, hidden: true },
79
+ },
80
+ user,
81
+ )
82
+ expect(notes).toStrictEqual([
83
+ { text: 'up', hidden: true },
84
+ { text: 'n', hidden: false },
85
+ ])
86
+ })
87
+
88
+ it('returns the rows of one UPDATE in id order, as it left them', async () => {
89
+ const board = await Board.create({ data: { name: 'b' } }, M)
90
+ const connect = { connect: { id: board.id } }
91
+ for (const [title, points] of [
92
+ ['c', 1],
93
+ ['a', 2],
94
+ ['b', 3],
95
+ ] as const) {
96
+ await Ticket.create({ data: { title, points, board: connect } }, M)
97
+ }
98
+ const where = { title: { in: ['c', 'a'] } }
99
+ const rows = await Ticket.updateManyAndReturn(
100
+ {
101
+ where,
102
+ data: { points: { multiply: 10 } },
103
+ select: { title: true, points: true },
104
+ },
105
+ M,
106
+ )
107
+ expect(rows).toStrictEqual([
108
+ { title: 'c', points: 10 },
109
+ { title: 'a', points: 20 },
110
+ ])
111
+ const shaped = await Ticket.updateManyAndReturn(
112
+ {
113
+ where,
114
+ data: { points: { increment: 1 } },
115
+ select: { points: true, board: { select: { _count: true } } },
116
+ },
117
+ M,
118
+ )
119
+ expect(shaped).toStrictEqual([
120
+ { points: 11, board: { _count: { tickets: 3, notes: 0 } } },
121
+ { points: 21, board: { _count: { tickets: 3, notes: 0 } } },
122
+ ])
123
+ })
124
+
125
+ it('returns each row its rule lets it update, read or not', async () => {
126
+ const board = await Board.create({ data: { name: 'b' } }, M)
127
+ const connect = { connect: { id: board.id } }
128
+ for (const text of ['q', 'p', 'r']) {
129
+ await Note.create({ data: { text, board: connect } }, M)
130
+ }
131
+ const rows = await Note.updateManyAndReturn(
132
+ {
133
+ where: { text: { not: 'r' } },
134
+ data: { hidden: true },
135
+ select: {
136
+ text: true,
137
+ hidden: true,
138
+ board: { select: { name: true } },
139
+ },
140
+ },
141
+ user,
142
+ )
143
+ expect(rows).toStrictEqual([
144
+ { text: 'q', hidden: true, board: { name: 'b' } },
145
+ { text: 'p', hidden: true, board: { name: 'b' } },
146
+ ])
147
+ const none = { where: { text: 'q' }, data: { text: 'x' } }
148
+ expect(await Note.updateManyAndReturn(none, user)).toEqual([])
149
+ })
150
+ })
151
+ }
@@ -0,0 +1,338 @@
1
+ // The cascades of the contract (D-54, D-102): what a delete does to the rows
2
+ // that reference it, when no hook runs, as Master and as a user.
3
+ import type { Adapter } from 'ultimabase'
4
+ import { defineModel, master } from 'ultimabase'
5
+ import { describe, expect, it } from 'vitest'
6
+ import { suiteUser } from './as-user.ts'
7
+ import { started } from './suite.ts'
8
+
9
+ const M = { as: master }
10
+ const open = () => true
11
+
12
+ const Shop = defineModel({
13
+ name: 'Shop',
14
+ fields: (f) => ({
15
+ name: f.string({ unique: true }),
16
+ aisles: f.many(() => Aisle),
17
+ items: f.many(() => Item),
18
+ clerks: f.many(() => Clerk),
19
+ orders: f.many(() => Order),
20
+ }),
21
+ }).with({ access: { read: open, delete: open } })
22
+
23
+ const Aisle = defineModel({
24
+ name: 'Aisle',
25
+ fields: (f) => ({
26
+ label: f.string(),
27
+ shop: f.one(() => Shop, { onDelete: 'cascade' }),
28
+ items: f.many(() => Item),
29
+ }),
30
+ })
31
+
32
+ /** Reached twice from a shop: through its aisle, and directly. */
33
+ const Item = defineModel({
34
+ name: 'Item',
35
+ fields: (f) => ({
36
+ label: f.string(),
37
+ aisle: f.one(() => Aisle, { onDelete: 'cascade' }),
38
+ shop: f.one(() => Shop, { onDelete: 'cascade' }),
39
+ orders: f.many(() => Order),
40
+ }),
41
+ })
42
+
43
+ /** An optional relation sets null by default. */
44
+ const Clerk = defineModel({
45
+ name: 'Clerk',
46
+ fields: (f) => ({
47
+ name: f.string(),
48
+ shop: f.one(() => Shop, { optional: true }),
49
+ }),
50
+ })
51
+
52
+ /** A required relation restricts by default. */
53
+ const Order = defineModel({
54
+ name: 'Order',
55
+ fields: (f) => ({
56
+ ref: f.string(),
57
+ item: f.one(() => Item),
58
+ shop: f.one(() => Shop, { onDelete: 'cascade' }),
59
+ }),
60
+ })
61
+
62
+ /** A cascade that comes back to its own model. */
63
+ const Folder = defineModel({
64
+ name: 'Folder',
65
+ fields: (f) => ({
66
+ name: f.string(),
67
+ parent: f.one(() => Folder, {
68
+ optional: true,
69
+ onDelete: 'cascade',
70
+ inverse: 'children',
71
+ }),
72
+ children: f.many(() => Folder),
73
+ }),
74
+ })
75
+
76
+ /** A model a delete both deletes and empties: its rows are loaded (D-102). */
77
+ const Room = defineModel({
78
+ name: 'Room',
79
+ fields: (f) => ({ seats: f.many(() => Seat), spares: f.many(() => Seat) }),
80
+ })
81
+ const Seat = defineModel({
82
+ name: 'Seat',
83
+ fields: (f) => ({
84
+ name: f.string(),
85
+ room: f.one(() => Room, { onDelete: 'cascade', inverse: 'seats' }),
86
+ spare: f.one(() => Room, { optional: true, inverse: 'spares' }),
87
+ }),
88
+ })
89
+
90
+ /** A model a delete empties twice: its rows are loaded (D-102). */
91
+ const Wall = defineModel({
92
+ name: 'Wall',
93
+ fields: (f) => ({
94
+ fronts: f.many(() => Poster),
95
+ backs: f.many(() => Poster),
96
+ }),
97
+ })
98
+ const Poster = defineModel({
99
+ name: 'Poster',
100
+ fields: (f) => ({
101
+ front: f.one(() => Wall, { optional: true, inverse: 'fronts' }),
102
+ back: f.one(() => Wall, { optional: true, inverse: 'backs' }),
103
+ }),
104
+ })
105
+
106
+ const by = (id: string) => ({ connect: { id } })
107
+ const user = { as: suiteUser('00000000-0000-4000-8000-000000000000') }
108
+
109
+ /** A shop with an aisle, an item in it and a clerk. */
110
+ const stocked = async (name: string) => {
111
+ const shop = await Shop.create({ data: { name } }, M)
112
+ const aisle = await Aisle.create(
113
+ { data: { label: `${name} aisle`, shop: by(shop.id) } },
114
+ M,
115
+ )
116
+ const item = await Item.create(
117
+ {
118
+ data: { label: `${name} item`, aisle: by(aisle.id), shop: by(shop.id) },
119
+ },
120
+ M,
121
+ )
122
+ const clerk = await Clerk.create(
123
+ { data: { name: `${name} clerk`, shop: by(shop.id) } },
124
+ M,
125
+ )
126
+ return { shop, item, clerk }
127
+ }
128
+ const order = async (item: string, shop: string) =>
129
+ Order.create({ data: { ref: 'o', item: by(item), shop: by(shop) } }, M)
130
+ const counts = async () => [
131
+ await Shop.count({}, M),
132
+ await Aisle.count({}, M),
133
+ await Item.count({}, M),
134
+ await Order.count({}, M),
135
+ ]
136
+
137
+ export function cascades(create: () => Adapter): void {
138
+ describe('cascades', () => {
139
+ started(create, [
140
+ Shop,
141
+ Aisle,
142
+ Item,
143
+ Clerk,
144
+ Order,
145
+ Folder,
146
+ Room,
147
+ Seat,
148
+ Wall,
149
+ Poster,
150
+ ])
151
+
152
+ it('deletes what a delete reaches, by every path, and returns the row as it was', async () => {
153
+ const reds = await stocked('reds')
154
+ const blues = await stocked('blues')
155
+ const removed = await Shop.delete({ where: { name: 'reds' } }, M)
156
+ expect(removed).toEqual(reds.shop)
157
+ expect(await counts()).toEqual([1, 1, 1, 0])
158
+ const clerk = await Clerk.findUnique(
159
+ { where: { id: reds.clerk.id }, include: { shop: true } },
160
+ M,
161
+ )
162
+ expect(clerk?.shop).toBeNull()
163
+ expect(clerk?.updatedAt.getTime()).toBeGreaterThanOrEqual(
164
+ reds.clerk.updatedAt.getTime(),
165
+ )
166
+ const kept = await Clerk.findUnique({ where: { id: blues.clerk.id } }, M)
167
+ expect(kept).toEqual(blues.clerk)
168
+ })
169
+
170
+ // D-135
171
+ it('returns the relations and counts of a delete as the caller read them before', async () => {
172
+ const reds = await stocked('reds')
173
+ const removed = await Shop.delete(
174
+ {
175
+ where: { id: reds.shop.id },
176
+ include: {
177
+ aisles: true,
178
+ clerks: { include: { shop: true } },
179
+ _count: true,
180
+ },
181
+ },
182
+ M,
183
+ )
184
+ expect(removed).toMatchObject({
185
+ name: 'reds',
186
+ aisles: [{ label: 'reds aisle' }],
187
+ clerks: [{ name: 'reds clerk', shop: { name: 'reds' } }],
188
+ _count: { aisles: 1, items: 1, clerks: 1, orders: 0 },
189
+ })
190
+ expect(await counts()).toEqual([0, 0, 0, 0])
191
+ const blues = await stocked('blues')
192
+ // A user reads no item: the model gives them no read rule.
193
+ const theirs = await Shop.delete(
194
+ {
195
+ where: { id: blues.shop.id },
196
+ select: { name: true, items: true, _count: true },
197
+ },
198
+ user,
199
+ )
200
+ expect(theirs).toEqual({
201
+ name: 'blues',
202
+ items: [],
203
+ _count: { aisles: 0, items: 0, clerks: 0, orders: 0 },
204
+ })
205
+ expect(await counts()).toEqual([0, 0, 0, 0])
206
+ })
207
+
208
+ it('refuses a delete a restricting row stops, and writes nothing', async () => {
209
+ const reds = await stocked('reds')
210
+ const blues = await stocked('blues')
211
+ await order(reds.item.id, blues.shop.id)
212
+ for (const caller of [M, user]) {
213
+ await expect(
214
+ Shop.delete({ where: { id: reds.shop.id } }, caller),
215
+ ).rejects.toMatchObject({
216
+ code: 'RELATION_RESTRICTED',
217
+ details: { model: 'Order', field: 'item' },
218
+ })
219
+ }
220
+ expect(await counts()).toEqual([2, 2, 2, 1])
221
+ const clerk = await Clerk.findUnique({ where: { id: reds.clerk.id } }, M)
222
+ expect(clerk).toEqual(reds.clerk)
223
+ })
224
+
225
+ it('lets go a restricting row the cascade removes', async () => {
226
+ const reds = await stocked('reds')
227
+ await order(reds.item.id, reds.shop.id)
228
+ await Shop.delete({ where: { id: reds.shop.id } }, user)
229
+ expect(await counts()).toEqual([0, 0, 0, 0])
230
+ })
231
+
232
+ it('refuses a missing row', async () => {
233
+ await expect(
234
+ Shop.delete({ where: { name: 'nowhere' } }, M),
235
+ ).rejects.toMatchObject({ code: 'NOT_FOUND' })
236
+ })
237
+
238
+ it('deletes many rows and what they reach, as Master and as a user', async () => {
239
+ const reds = await stocked('reds')
240
+ await stocked('blues')
241
+ await stocked('greens')
242
+ const names = { name: { in: ['reds', 'blues'] } }
243
+ const removed = await Shop.deleteMany({ where: names }, M)
244
+ expect(removed).toEqual({ count: 2 })
245
+ expect(await counts()).toEqual([1, 1, 1, 0])
246
+ const clerk = await Clerk.findUnique(
247
+ { where: { id: reds.clerk.id }, include: { shop: true } },
248
+ M,
249
+ )
250
+ expect(clerk?.shop).toBeNull()
251
+ expect(await Shop.deleteMany({ where: names }, M)).toEqual({ count: 0 })
252
+ expect(await Shop.deleteMany({}, user)).toEqual({ count: 1 })
253
+ expect(await counts()).toEqual([0, 0, 0, 0])
254
+ })
255
+
256
+ it('deletes many rows chosen by the rows their delete reaches', async () => {
257
+ await stocked('reds')
258
+ await stocked('blues')
259
+ await stocked('greens')
260
+ // Through a cascade, then through a `setNull`.
261
+ const aisle = { aisles: { some: { label: 'reds aisle' } } }
262
+ expect(await Shop.deleteMany({ where: aisle }, M)).toEqual({ count: 1 })
263
+ const clerk = { clerks: { some: { name: 'blues clerk' } } }
264
+ expect(await Shop.deleteMany({ where: clerk }, M)).toEqual({ count: 1 })
265
+ const left = await Shop.findMany({}, M)
266
+ expect(left.map((shop) => shop.name)).toEqual(['greens'])
267
+ expect(await counts()).toEqual([1, 1, 1, 0])
268
+ })
269
+
270
+ it('deletes no row of many when a restricting row stops one', async () => {
271
+ const reds = await stocked('reds')
272
+ const blues = await stocked('blues')
273
+ await order(reds.item.id, blues.shop.id)
274
+ for (const caller of [M, user]) {
275
+ await expect(
276
+ Shop.deleteMany({ where: { name: 'reds' } }, caller),
277
+ ).rejects.toMatchObject({
278
+ code: 'RELATION_RESTRICTED',
279
+ details: { model: 'Order', field: 'item' },
280
+ })
281
+ }
282
+ expect(await counts()).toEqual([2, 2, 2, 1])
283
+ })
284
+
285
+ it('deletes many trees of one model', async () => {
286
+ const root = await Folder.create({ data: { name: 'root' } }, M)
287
+ await Folder.create({ data: { name: 'child', parent: by(root.id) } }, M)
288
+ await Folder.create({ data: { name: 'other' } }, M)
289
+ await Folder.create({ data: { name: 'kept' } }, M)
290
+ const roots = { name: { in: ['root', 'other'] } }
291
+ expect(await Folder.deleteMany({ where: roots }, M)).toEqual({
292
+ count: 2,
293
+ })
294
+ const left = await Folder.findMany({}, M)
295
+ expect(left.map((folder) => folder.name)).toEqual(['kept'])
296
+ })
297
+
298
+ it('follows a cascade back to its own model', async () => {
299
+ const root = await Folder.create({ data: { name: 'root' } }, M)
300
+ const child = await Folder.create(
301
+ { data: { name: 'child', parent: by(root.id) } },
302
+ M,
303
+ )
304
+ await Folder.create({ data: { name: 'leaf', parent: by(child.id) } }, M)
305
+ await Folder.create({ data: { name: 'other' } }, M)
306
+ await Folder.delete({ where: { id: root.id } }, M)
307
+ const left = await Folder.findMany({}, M)
308
+ expect(left.map((folder) => folder.name)).toEqual(['other'])
309
+ })
310
+
311
+ it('empties the key of a row it leaves, of a model it deletes', async () => {
312
+ const hall = await Room.create({ data: {} }, M)
313
+ const lobby = await Room.create({ data: {} }, M)
314
+ await Seat.create({ data: { name: 'in', room: by(hall.id) } }, M)
315
+ await Seat.create(
316
+ { data: { name: 'out', room: by(lobby.id), spare: by(hall.id) } },
317
+ M,
318
+ )
319
+ await Room.delete({ where: { id: hall.id } }, M)
320
+ const left = await Seat.findMany({ include: { spare: true } }, M)
321
+ expect(left).toMatchObject([{ name: 'out', spare: null }])
322
+ })
323
+
324
+ it('empties two keys of a row that names the deleted row twice', async () => {
325
+ const wall = await Wall.create({ data: {} }, M)
326
+ const poster = await Poster.create(
327
+ { data: { front: by(wall.id), back: by(wall.id) } },
328
+ M,
329
+ )
330
+ await Wall.delete({ where: { id: wall.id } }, M)
331
+ const left = await Poster.findUnique(
332
+ { where: { id: poster.id }, include: { front: true, back: true } },
333
+ M,
334
+ )
335
+ expect(left).toMatchObject({ front: null, back: null })
336
+ })
337
+ })
338
+ }