@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/feeds.ts ADDED
@@ -0,0 +1,197 @@
1
+ // The change feed of the contract (D-154): what each commit changed, once
2
+ // committed, and nothing a rollback or a failed write undid.
3
+ import type { Adapter, Changed } from 'ultimabase'
4
+ import {
5
+ defineApp,
6
+ defineModel,
7
+ definePlugin,
8
+ master,
9
+ transaction,
10
+ } from 'ultimabase'
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
12
+ import { silent } from './silent.ts'
13
+
14
+ const M = { as: master }
15
+
16
+ const Card = defineModel({
17
+ name: 'Card',
18
+ fields: (f) => ({
19
+ title: f.string({ unique: true }),
20
+ rank: f.number({ integer: true, optional: true }),
21
+ tags: f.many(() => Tag),
22
+ }),
23
+ })
24
+
25
+ const Tag = defineModel({
26
+ name: 'Tag',
27
+ fields: (f) => ({ label: f.string(), cards: f.many(() => Card) }),
28
+ })
29
+
30
+ const long = Array.from({ length: 30 }, (_, index) =>
31
+ `field${index}`.padEnd(52, 'x'),
32
+ )
33
+ const Wide = defineModel({
34
+ name: 'Wide',
35
+ fields: (f) =>
36
+ Object.fromEntries(
37
+ long.map((name) => [name, f.string({ optional: true })]),
38
+ ),
39
+ })
40
+
41
+ /** Written last: once its insert arrives, so have the changes before it. */
42
+ const Mark = defineModel({
43
+ name: 'Mark',
44
+ fields: (f) => ({ at: f.number({ integer: true }) }),
45
+ })
46
+
47
+ const manifest = {
48
+ name: 'watcher',
49
+ version: '1.0.0',
50
+ peerDependencies: { ultimabase: '*' },
51
+ }
52
+
53
+ /** The changes `seen` holds, one entry per kind and target, lists sorted. */
54
+ function merged(seen: readonly Changed[]): object[] {
55
+ const entries = new Map<string, { change: Changed; listed?: string[] }>()
56
+ for (const change of seen) {
57
+ const target = 'field' in change ? change.field : change.fields?.toSorted()
58
+ const key = JSON.stringify([change.kind, change.model, target])
59
+ const rows = 'field' in change ? change.pairs : change.ids
60
+ const known = entries.get(key)
61
+ const listed =
62
+ rows === undefined || (known !== undefined && known.listed === undefined)
63
+ ? undefined
64
+ : [
65
+ ...(known?.listed ?? []),
66
+ ...rows.map((row) => (typeof row === 'string' ? row : row.join())),
67
+ ]
68
+ entries.set(key, listed === undefined ? { change } : { change, listed })
69
+ }
70
+ return [...entries.values()].map(({ change, listed }) => {
71
+ const { kind, model } = change
72
+ const rows = listed?.toSorted()
73
+ if ('field' in change) return { kind, model, field: change.field, rows }
74
+ return { kind, model, fields: change.fields?.toSorted(), rows }
75
+ })
76
+ }
77
+
78
+ export function feeds(create: () => Adapter): void {
79
+ describe('the change feed', () => {
80
+ let seen: Changed[] = []
81
+ let marks = 0
82
+ let app: ReturnType<typeof defineApp>
83
+ const watcher = definePlugin({
84
+ name: 'watcher',
85
+ manifest,
86
+ changed: (changes) => {
87
+ seen.push(...changes)
88
+ },
89
+ })
90
+ beforeEach(async () => {
91
+ app = defineApp({
92
+ database: create(),
93
+ models: [Card, Tag, Wide, Mark],
94
+ plugins: [watcher.plugin()],
95
+ logger: silent,
96
+ })
97
+ await app.start()
98
+ seen = []
99
+ })
100
+ afterEach(async () => app.stop())
101
+
102
+ /** The changes the commits so far made, once all of them arrived. */
103
+ const settled = async (): Promise<object[]> => {
104
+ marks += 1
105
+ const { id } = await Mark.create({ data: { at: marks } }, M)
106
+ await vi.waitFor(() => {
107
+ expect(
108
+ seen.some((change) => 'ids' in change && change.ids?.includes(id)),
109
+ ).toBe(true)
110
+ })
111
+ const changes = merged(seen.filter((change) => change.model !== 'Mark'))
112
+ seen = []
113
+ return changes
114
+ }
115
+
116
+ it('reports the rows each commit inserted, updated and deleted', async () => {
117
+ const a = await Card.create({ data: { title: 'a' } }, M)
118
+ const b = await Card.create({ data: { title: 'b' } }, M)
119
+ await Card.update({ where: { id: a.id }, data: { rank: 2 } }, M)
120
+ await Card.delete({ where: { id: b.id } }, M)
121
+ expect(await settled()).toEqual([
122
+ { kind: 'insert', model: 'Card', rows: [a.id, b.id].toSorted() },
123
+ {
124
+ kind: 'update',
125
+ model: 'Card',
126
+ fields: ['rank', 'updatedAt'],
127
+ rows: [a.id],
128
+ },
129
+ { kind: 'delete', model: 'Card', rows: [b.id] },
130
+ ])
131
+ })
132
+
133
+ it('reports the links a write added or removed, not those it found', async () => {
134
+ const card = await Card.create({ data: { title: 'a' } }, M)
135
+ const tag = await Tag.create({ data: { label: 'red' } }, M)
136
+ await settled()
137
+ const tags = { connect: [{ id: tag.id }] }
138
+ await Card.update({ where: { id: card.id }, data: { tags } }, M)
139
+ await Card.update({ where: { id: card.id }, data: { tags } }, M)
140
+ const pair = [card.id, tag.id].join()
141
+ const link = { model: 'Card', field: 'tags', rows: [pair] }
142
+ const updated = { kind: 'update', model: 'Card', fields: ['updatedAt'] }
143
+ expect(await settled()).toEqual([
144
+ { ...updated, rows: [card.id, card.id] },
145
+ { kind: 'link', ...link },
146
+ ])
147
+ const disconnect = [{ id: tag.id }]
148
+ await Card.update(
149
+ { where: { id: card.id }, data: { tags: { disconnect } } },
150
+ M,
151
+ )
152
+ expect(await settled()).toEqual([
153
+ { ...updated, rows: [card.id] },
154
+ { kind: 'unlink', ...link },
155
+ ])
156
+ })
157
+
158
+ it('reports nothing of a rollback, a failed write or an empty update', async () => {
159
+ await Card.create({ data: { title: 'a' } }, M)
160
+ await settled()
161
+ const failed = transaction(async () => {
162
+ await Card.create({ data: { title: 'b' } }, M)
163
+ throw new Error('Stop.')
164
+ })
165
+ await expect(failed).rejects.toThrow('Stop.')
166
+ const taken = Card.create({ data: { title: 'a' } }, M)
167
+ await expect(taken).rejects.toMatchObject({ code: 'UNIQUE_VIOLATION' })
168
+ await Card.updateMany({ where: { title: 'none' }, data: { rank: 1 } }, M)
169
+ expect(await settled()).toEqual([])
170
+ })
171
+
172
+ it("reports a transaction's writes once it commits", async () => {
173
+ const [a, b] = await transaction(async () => [
174
+ await Card.create({ data: { title: 'a' } }, M),
175
+ await Card.create({ data: { title: 'b' } }, M),
176
+ ])
177
+ expect(await settled()).toEqual([
178
+ { kind: 'insert', model: 'Card', rows: [a?.id, b?.id].toSorted() },
179
+ ])
180
+ })
181
+
182
+ it('lists no ids past a thousand, no fields past their limit', async () => {
183
+ const data = Array.from({ length: 1001 }, (_, index) => ({
184
+ title: `${index}`,
185
+ }))
186
+ await Card.createMany({ data }, M)
187
+ const wide = await Wide.create({ data: {} }, M)
188
+ const set = Object.fromEntries(long.map((name) => [name, 'v']))
189
+ await Wide.update({ where: { id: wide.id }, data: set }, M)
190
+ expect(await settled()).toEqual([
191
+ { kind: 'insert', model: 'Card', rows: undefined },
192
+ { kind: 'insert', model: 'Wide', rows: [wide.id] },
193
+ { kind: 'update', model: 'Wide', fields: undefined, rows: [wide.id] },
194
+ ])
195
+ })
196
+ })
197
+ }
package/src/groups.ts ADDED
@@ -0,0 +1,185 @@
1
+ // Groups in the contract (D-146): rows with no value form one group, a
2
+ // relation is its target's id or `null` when the caller cannot read it, and
3
+ // the order is total, keys ascending after `orderBy`, nulls last; `having`
4
+ // keeps the groups by their values and aggregates (D-149).
5
+ import type { Adapter, Identity } from 'ultimabase'
6
+ import { defineModel, master } from 'ultimabase'
7
+ import { beforeEach, describe, expect, it } from 'vitest'
8
+ import { suiteUser } from './as-user.ts'
9
+ import { started } from './suite.ts'
10
+
11
+ const M: { readonly as: Identity } = { as: master }
12
+ const user = { as: suiteUser('00000000-0000-4000-8000-000000000000') }
13
+
14
+ /** A user reads the teams that are not hidden. */
15
+ const Team = defineModel({
16
+ name: 'Team',
17
+ fields: (f) => ({
18
+ hidden: f.boolean({ default: false }),
19
+ deals: f.many(() => Deal),
20
+ }),
21
+ }).with({ access: { read: ({ where }) => where({ hidden: false }) } })
22
+
23
+ const Deal = defineModel({
24
+ name: 'Deal',
25
+ fields: (f) => ({
26
+ stage: f.enumOf(['b', 'a', 'c']),
27
+ amount: f.decimal({ precision: 8, scale: 2 }),
28
+ units: f.number({ integer: true, optional: true }),
29
+ on: f.dateTime({ optional: true }),
30
+ region: f.string({ optional: true }),
31
+ team: f.one(() => Team, { optional: true }),
32
+ }),
33
+ }).with({ access: { read: () => true } })
34
+
35
+ const day = (n: number): Date => new Date(Date.UTC(2026, 4, n, 12, 30))
36
+ const stages = async (
37
+ kept: Promise<readonly { readonly stage: string }[]>,
38
+ ): Promise<string[]> => (await kept).map((group) => group.stage)
39
+
40
+ export function groups(create: () => Adapter): void {
41
+ describe('groups', () => {
42
+ started(create, [Team, Deal])
43
+
44
+ let red = ''
45
+ let blue = ''
46
+ beforeEach(async () => {
47
+ red = (await Team.create({ data: {} }, M)).id
48
+ blue = (await Team.create({ data: { hidden: true } }, M)).id
49
+ const rows = [
50
+ { stage: 'a', amount: '1.10', units: 2, on: day(1), region: 'n' },
51
+ { stage: 'a', amount: '2.20', units: 3, on: day(1), region: 'n' },
52
+ { stage: 'b', amount: '5', on: day(2), region: 's' },
53
+ { stage: 'c', amount: '10', units: 1 },
54
+ { stage: 'b', amount: '1', units: 4, on: day(2), region: 's' },
55
+ ] as const
56
+ const teams = [red, blue, red, undefined, red]
57
+ for (const [at, row] of rows.entries()) {
58
+ const id = teams[at]
59
+ const data =
60
+ id === undefined ? row : { ...row, team: { connect: { id } } }
61
+ await Deal.create({ data }, M)
62
+ }
63
+ })
64
+
65
+ it('groups by scalars and dates, the rows with no value together', async () => {
66
+ const result = await Deal.groupBy(
67
+ { by: ['region', 'on'], _count: true, _sum: { amount: true } },
68
+ M,
69
+ )
70
+ expect(result).toStrictEqual([
71
+ { region: 'n', on: day(1), _count: 2, _sum: { amount: '3.30' } },
72
+ { region: 's', on: day(2), _count: 2, _sum: { amount: '6.00' } },
73
+ { region: null, on: null, _count: 1, _sum: { amount: '10.00' } },
74
+ ])
75
+ const regions = await Deal.groupBy(
76
+ { by: ['region'], orderBy: { region: 'desc' } },
77
+ M,
78
+ )
79
+ expect(regions).toStrictEqual([
80
+ { region: null },
81
+ { region: 's' },
82
+ { region: 'n' },
83
+ ])
84
+ })
85
+
86
+ it('sorts by an aggregate, then by the keys, and pages', async () => {
87
+ const counted = await Deal.groupBy(
88
+ { by: ['stage'], orderBy: { _count: 'desc' }, _max: { units: true } },
89
+ M,
90
+ )
91
+ expect(counted).toStrictEqual([
92
+ { stage: 'a', _max: { units: 3 } },
93
+ { stage: 'b', _max: { units: 4 } },
94
+ { stage: 'c', _max: { units: 1 } },
95
+ ])
96
+ const summed = { _sum: { amount: 'desc' } } as const
97
+ const sorted = await Deal.groupBy({ by: ['stage'], orderBy: summed }, M)
98
+ expect(sorted).toStrictEqual([
99
+ { stage: 'c' },
100
+ { stage: 'b' },
101
+ { stage: 'a' },
102
+ ])
103
+ const paged = await Deal.groupBy(
104
+ { by: ['stage'], orderBy: summed, skip: 1, take: 1 },
105
+ M,
106
+ )
107
+ expect(paged).toStrictEqual([{ stage: 'b' }])
108
+ })
109
+
110
+ it('groups by a relation, null when unset or unread', async () => {
111
+ const args = {
112
+ by: ['team'],
113
+ orderBy: { _count: 'desc' },
114
+ _count: true,
115
+ } as const
116
+ expect(await Deal.groupBy(args, M)).toStrictEqual([
117
+ { team: { id: red }, _count: 3 },
118
+ { team: { id: blue }, _count: 1 },
119
+ { team: null, _count: 1 },
120
+ ])
121
+ expect(await Deal.groupBy(args, user)).toStrictEqual([
122
+ { team: { id: red }, _count: 3 },
123
+ { team: null, _count: 2 },
124
+ ])
125
+ })
126
+
127
+ it('keeps the groups having passes, by their values and aggregates', async () => {
128
+ const by = ['stage'] as const
129
+ const sum = { amount: { _sum: { gt: '5' } } } as const
130
+ expect(await stages(Deal.groupBy({ by, having: sum }, M))).toEqual([
131
+ 'b',
132
+ 'c',
133
+ ])
134
+ const counts = {
135
+ stage: { in: ['a', 'b'] },
136
+ amount: { _count: 2 },
137
+ } as const
138
+ expect(await stages(Deal.groupBy({ by, having: counts }, M))).toEqual([
139
+ 'a',
140
+ 'b',
141
+ ])
142
+ const average = { units: { _avg: { lt: 3 } } } as const
143
+ expect(await stages(Deal.groupBy({ by, having: average }, M))).toEqual([
144
+ 'a',
145
+ 'c',
146
+ ])
147
+ const least = { on: { _min: { gte: day(2) } } } as const
148
+ expect(await stages(Deal.groupBy({ by, having: least }, M))).toEqual([
149
+ 'b',
150
+ ])
151
+ const either = {
152
+ OR: [{ stage: 'c' }, { units: { _max: { gte: 4 } } }],
153
+ } as const
154
+ expect(await stages(Deal.groupBy({ by, having: either }, M))).toEqual([
155
+ 'b',
156
+ 'c',
157
+ ])
158
+ const dated = { NOT: { on: { _min: { equals: null } } } } as const
159
+ expect(await stages(Deal.groupBy({ by, having: dated }, M))).toEqual([
160
+ 'a',
161
+ 'b',
162
+ ])
163
+ const counted = await Deal.groupBy(
164
+ {
165
+ by: ['stage', 'region'],
166
+ _count: true,
167
+ having: { region: { not: null }, amount: { _count: { gt: 1 } } },
168
+ orderBy: { _sum: { amount: 'desc' } },
169
+ },
170
+ M,
171
+ )
172
+ expect(counted).toStrictEqual([
173
+ { stage: 'b', region: 's', _count: 2 },
174
+ { stage: 'a', region: 'n', _count: 2 },
175
+ ])
176
+ })
177
+
178
+ it('gives no groups without rows', async () => {
179
+ const where = { region: 'w' }
180
+ expect(
181
+ await Deal.groupBy({ by: ['stage'], where, _count: true }, M),
182
+ ).toStrictEqual([])
183
+ })
184
+ })
185
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ // `@ultimabase/testing` (D-40): an application's tests (D-130), the adapter
2
+ // contract suite (D-94) and a test's user caller (D-80).
3
+ export { asUser } from './as-user.ts'
4
+ export { testAdapter } from './test-adapter.ts'
5
+ export type { TestApplication, TestOptions } from './test-application.ts'
6
+ export { testApplication } from './test-application.ts'
@@ -0,0 +1,185 @@
1
+ // A json value filtered at a path (D-149): `#>` reaches the value, a missing
2
+ // one is unknown, and each operator tests a value of its own JSON type.
3
+ import type { Adapter, CreateDataOf, JsonValue, WhereOf } from 'ultimabase'
4
+ import { defineModel, master } from 'ultimabase'
5
+ import { describe, expect, it } from 'vitest'
6
+ import { started } from './suite.ts'
7
+
8
+ const M = { as: master }
9
+
10
+ const Doc = defineModel({
11
+ name: 'Doc',
12
+ fields: (f) => ({
13
+ name: f.string({ unique: true }),
14
+ meta: f.json({ optional: true }),
15
+ }),
16
+ })
17
+
18
+ const rows: readonly CreateDataOf<typeof Doc>[] = [
19
+ {
20
+ name: 'ada',
21
+ meta: {
22
+ author: { name: 'Ada', age: 36 },
23
+ tags: ['x', 'y'],
24
+ items: [{ id: 1, kind: 'a' }, { id: 2 }],
25
+ note: null,
26
+ score: 5,
27
+ },
28
+ },
29
+ {
30
+ name: 'bob',
31
+ meta: { author: { name: 'bob' }, tags: ['y'], items: [], score: 'high' },
32
+ },
33
+ { name: 'cy', meta: [1, 2, 3] },
34
+ { name: 'nil' },
35
+ { name: 'str', meta: '50%_off' },
36
+ ]
37
+
38
+ const names = async (where: WhereOf<typeof Doc>): Promise<string[]> =>
39
+ (await Doc.findMany({ where, orderBy: { name: 'asc' } }, M)).map(
40
+ (doc) => doc.name,
41
+ )
42
+
43
+ /** The rows the path filter keeps, and the rows its `NOT` keeps. */
44
+ const kept = async (meta: object): Promise<[string[], string[]]> => [
45
+ await names({ meta }),
46
+ await names({ NOT: { meta } }),
47
+ ]
48
+
49
+ const contains = (
50
+ path: string[],
51
+ arrayContains: JsonValue,
52
+ ): Promise<string[]> => names({ meta: { path, arrayContains } })
53
+
54
+ const at = (path: string[], equals: JsonValue): Promise<string[]> =>
55
+ names({ meta: { path, equals } })
56
+
57
+ export function jsonPaths(create: () => Adapter): void {
58
+ describe('json paths', () => {
59
+ started(create, [Doc])
60
+
61
+ const seeded = async (): Promise<void> => {
62
+ for (const data of rows) await Doc.create({ data }, M)
63
+ }
64
+
65
+ it('tests equality at a path, unknown where the path is missing', async () => {
66
+ await seeded()
67
+ const name = ['author', 'name']
68
+ expect(await kept({ path: name, equals: 'Ada' })).toEqual([
69
+ ['ada'],
70
+ ['bob'],
71
+ ])
72
+ expect(await kept({ path: name, not: 'Ada' })).toEqual([['bob'], ['ada']])
73
+ expect(await names({ meta: { path: ['note'], equals: null } })).toEqual([
74
+ 'ada',
75
+ ])
76
+ expect(await names({ meta: { path: [], equals: [1, 2, 3] } })).toEqual([
77
+ 'cy',
78
+ ])
79
+ expect(await names({ meta: { path: [], equals: '50%_off' } })).toEqual([
80
+ 'str',
81
+ ])
82
+ const author = { path: ['author'], equals: { age: 36, name: 'Ada' } }
83
+ expect(await names({ meta: author })).toEqual(['ada'])
84
+ })
85
+
86
+ it('compares numbers by value and strings by code point', async () => {
87
+ await seeded()
88
+ const score = ['score']
89
+ expect(await kept({ path: score, gt: 3 })).toEqual([['ada'], ['bob']])
90
+ expect(await kept({ path: score, lt: 10 })).toEqual([['ada'], ['bob']])
91
+ expect(await kept({ path: score, gte: 'high' })).toEqual([
92
+ ['bob'],
93
+ ['ada'],
94
+ ])
95
+ expect(await names({ meta: { path: score, lte: 5 } })).toEqual(['ada'])
96
+ const name = ['author', 'name']
97
+ expect(await kept({ path: name, gt: 'Z' })).toEqual([['bob'], ['ada']])
98
+ })
99
+
100
+ it('matches strings literally, and insensitively with mode', async () => {
101
+ await seeded()
102
+ const name = ['author', 'name']
103
+ expect(await names({ meta: { path: [], stringContains: '%_' } })).toEqual(
104
+ ['str'],
105
+ )
106
+ expect(
107
+ await names({ meta: { path: name, stringContains: '%' } }),
108
+ ).toEqual([])
109
+ expect(
110
+ await names({ meta: { path: name, stringStartsWith: 'a' } }),
111
+ ).toEqual([])
112
+ const insensitive = { mode: 'insensitive' } as const
113
+ expect(
114
+ await names({
115
+ meta: { path: name, stringStartsWith: 'a', ...insensitive },
116
+ }),
117
+ ).toEqual(['ada'])
118
+ expect(
119
+ await names({
120
+ meta: { path: name, stringEndsWith: 'B', ...insensitive },
121
+ }),
122
+ ).toEqual(['bob'])
123
+ expect(await kept({ path: ['score'], stringContains: 'h' })).toEqual([
124
+ ['bob'],
125
+ ['ada'],
126
+ ])
127
+ })
128
+
129
+ it('tests arrays with arrayContains, arrayStartsWith and arrayEndsWith', async () => {
130
+ await seeded()
131
+ const tags = ['tags']
132
+ expect(await contains(tags, 'y')).toEqual(['ada', 'bob'])
133
+ expect(await contains(tags, ['y', 'x'])).toEqual(['ada'])
134
+ expect(await contains(tags, [])).toEqual(['ada', 'bob'])
135
+ expect(await contains(['items'], { id: 1 })).toEqual(['ada'])
136
+ expect(await contains([], 2)).toEqual(['cy'])
137
+ expect(await contains([], [[1]])).toEqual([])
138
+ expect(await kept({ path: ['author'], arrayContains: 'Ada' })).toEqual([
139
+ [],
140
+ ['ada', 'bob'],
141
+ ])
142
+ expect(
143
+ await names({ meta: { path: tags, arrayStartsWith: 'x' } }),
144
+ ).toEqual(['ada'])
145
+ expect(await names({ meta: { path: tags, arrayEndsWith: 'y' } })).toEqual(
146
+ ['ada', 'bob'],
147
+ )
148
+ const first = { path: ['items'], arrayStartsWith: { kind: 'a', id: 1 } }
149
+ expect(await kept(first)).toEqual([['ada'], ['bob']])
150
+ expect(await kept({ path: ['items'], arrayEndsWith: { id: 2 } })).toEqual(
151
+ [['ada'], ['bob']],
152
+ )
153
+ })
154
+
155
+ it('reads an array index as PostgreSQL does', async () => {
156
+ await seeded()
157
+ expect(await at(['items', '0', 'id'], 1)).toEqual(['ada'])
158
+ expect(await at(['items', '-1', 'id'], 2)).toEqual(['ada'])
159
+ expect(await at(['0'], 1)).toEqual(['cy'])
160
+ expect(await at(['-3'], 1)).toEqual(['cy'])
161
+ expect(await at(['-0'], 1)).toEqual(['cy'])
162
+ expect(await at([' 1'], 2)).toEqual(['cy'])
163
+ expect(await at(['+1'], 2)).toEqual(['cy'])
164
+ for (const key of ['1 ', '-4', '3', '99999999999', 'one']) {
165
+ expect(await kept({ path: [key], equals: 2 })).toEqual([[], []])
166
+ }
167
+ expect(await at(['tags', '0', 'x'], 'x')).toEqual([])
168
+ })
169
+
170
+ it('filters nothing with a path and no operator', async () => {
171
+ await seeded()
172
+ const all = ['ada', 'bob', 'cy', 'nil', 'str']
173
+ expect(await names({ meta: { path: ['author'] } })).toEqual(all)
174
+ expect(
175
+ await names({ meta: { path: ['author'], mode: 'insensitive' } }),
176
+ ).toEqual(all)
177
+ const both = {
178
+ path: ['author', 'name'],
179
+ stringStartsWith: 'A',
180
+ not: 'Ada',
181
+ }
182
+ expect(await names({ meta: both })).toEqual([])
183
+ })
184
+ })
185
+ }