@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.
- package/CHANGELOG.md +19 -0
- package/README.md +4 -2
- package/dist/aggregates.d.ts +3 -0
- package/dist/aggregates.d.ts.map +1 -0
- package/dist/aggregates.js +99 -0
- package/dist/as-user.d.ts +13 -0
- package/dist/as-user.d.ts.map +1 -0
- package/dist/as-user.js +17 -0
- package/dist/bulk-returns.d.ts +3 -0
- package/dist/bulk-returns.d.ts.map +1 -0
- package/dist/bulk-returns.js +125 -0
- package/dist/cascades.d.ts +3 -0
- package/dist/cascades.d.ts.map +1 -0
- package/dist/cascades.js +269 -0
- package/dist/cases.d.ts +3 -0
- package/dist/cases.d.ts.map +1 -0
- package/dist/cases.js +60 -0
- package/dist/counts.d.ts +3 -0
- package/dist/counts.d.ts.map +1 -0
- package/dist/counts.js +158 -0
- package/dist/days.d.ts +3 -0
- package/dist/days.d.ts.map +1 -0
- package/dist/days.js +74 -0
- package/dist/decimals.d.ts +3 -0
- package/dist/decimals.d.ts.map +1 -0
- package/dist/decimals.js +70 -0
- package/dist/distincts.d.ts +3 -0
- package/dist/distincts.d.ts.map +1 -0
- package/dist/distincts.js +80 -0
- package/dist/feeds.d.ts +3 -0
- package/dist/feeds.d.ts.map +1 -0
- package/dist/feeds.js +164 -0
- package/dist/groups.d.ts +3 -0
- package/dist/groups.d.ts.map +1 -0
- package/dist/groups.js +149 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/json-paths.d.ts +3 -0
- package/dist/json-paths.d.ts.map +1 -0
- package/dist/json-paths.js +143 -0
- package/dist/lists.d.ts +3 -0
- package/dist/lists.d.ts.map +1 -0
- package/dist/lists.js +234 -0
- package/dist/nested.d.ts +3 -0
- package/dist/nested.d.ts.map +1 -0
- package/dist/nested.js +152 -0
- package/dist/numbers.d.ts +3 -0
- package/dist/numbers.d.ts.map +1 -0
- package/dist/numbers.js +92 -0
- package/dist/projection.d.ts +3 -0
- package/dist/projection.d.ts.map +1 -0
- package/dist/projection.js +53 -0
- package/dist/reads.d.ts +3 -0
- package/dist/reads.d.ts.map +1 -0
- package/dist/reads.js +312 -0
- package/dist/relation-filters.d.ts +3 -0
- package/dist/relation-filters.d.ts.map +1 -0
- package/dist/relation-filters.js +116 -0
- package/dist/round-trips.d.ts +3 -0
- package/dist/round-trips.d.ts.map +1 -0
- package/dist/round-trips.js +284 -0
- package/dist/searches.d.ts +3 -0
- package/dist/searches.d.ts.map +1 -0
- package/dist/searches.js +57 -0
- package/dist/silent.d.ts +3 -0
- package/dist/silent.d.ts.map +1 -0
- package/dist/silent.js +7 -0
- package/dist/sorts.d.ts +3 -0
- package/dist/sorts.d.ts.map +1 -0
- package/dist/sorts.js +108 -0
- package/dist/suite.d.ts +7 -0
- package/dist/suite.d.ts.map +1 -0
- package/dist/suite.js +15 -0
- package/dist/test-adapter.d.ts +11 -0
- package/dist/test-adapter.d.ts.map +1 -0
- package/dist/test-adapter.js +55 -0
- package/dist/test-application.d.ts +17 -0
- package/dist/test-application.d.ts.map +1 -0
- package/dist/test-application.js +68 -0
- package/dist/transactions.d.ts +3 -0
- package/dist/transactions.d.ts.map +1 -0
- package/dist/transactions.js +61 -0
- package/dist/virtuals.d.ts +3 -0
- package/dist/virtuals.d.ts.map +1 -0
- package/dist/virtuals.js +135 -0
- package/dist/writes.d.ts +3 -0
- package/dist/writes.d.ts.map +1 -0
- package/dist/writes.js +219 -0
- package/package.json +40 -3
- package/src/aggregates.ts +128 -0
- package/src/as-user.ts +26 -0
- package/src/bulk-returns.ts +151 -0
- package/src/cascades.ts +338 -0
- package/src/cases.ts +76 -0
- package/src/counts.ts +223 -0
- package/src/days.ts +107 -0
- package/src/decimals.ts +102 -0
- package/src/distincts.ts +107 -0
- package/src/feeds.ts +197 -0
- package/src/groups.ts +185 -0
- package/src/index.ts +6 -0
- package/src/json-paths.ts +185 -0
- package/src/lists.ts +304 -0
- package/src/nested.ts +187 -0
- package/src/numbers.ts +125 -0
- package/src/projection.ts +65 -0
- package/src/reads.ts +389 -0
- package/src/relation-filters.ts +151 -0
- package/src/round-trips.ts +392 -0
- package/src/searches.ts +82 -0
- package/src/silent.ts +11 -0
- package/src/sorts.ts +143 -0
- package/src/suite.ts +21 -0
- package/src/test-adapter.ts +68 -0
- package/src/test-application.ts +93 -0
- package/src/transactions.ts +76 -0
- package/src/virtuals.ts +184 -0
- package/src/writes.ts +315 -0
package/src/cases.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Case-insensitive filters in the contract (D-140): `mode: 'insensitive'`
|
|
2
|
+
// on every string operator, beyond ASCII, a pattern character kept.
|
|
3
|
+
import type { Adapter, Identity, WhereOf } from 'ultimabase'
|
|
4
|
+
import { defineModel, master } from 'ultimabase'
|
|
5
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
6
|
+
import { started } from './suite.ts'
|
|
7
|
+
|
|
8
|
+
const M: { readonly as: Identity } = { as: master }
|
|
9
|
+
|
|
10
|
+
const Title = defineModel({
|
|
11
|
+
name: 'Title',
|
|
12
|
+
fields: (f) => ({ text: f.string({ optional: true }) }),
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const texts = async (where: WhereOf<typeof Title>): Promise<unknown[]> =>
|
|
16
|
+
(await Title.findMany({ where, orderBy: { text: 'asc' } }, M)).map(
|
|
17
|
+
(title) => title.text,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
export function cases(create: () => Adapter): void {
|
|
21
|
+
describe('case-insensitive filters', () => {
|
|
22
|
+
started(create, [Title])
|
|
23
|
+
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
for (const text of ['Analysis', 'Compilers', 'Engines', 'Émile', null]) {
|
|
26
|
+
await Title.create({ data: { text } }, M)
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('ignores case with every string operator', async () => {
|
|
31
|
+
const mode = 'insensitive'
|
|
32
|
+
expect(await texts({ text: { equals: 'analysis', mode } })).toEqual([
|
|
33
|
+
'Analysis',
|
|
34
|
+
])
|
|
35
|
+
expect(await texts({ text: { not: 'ANALYSIS', mode } })).toEqual([
|
|
36
|
+
'Compilers',
|
|
37
|
+
'Engines',
|
|
38
|
+
'Émile',
|
|
39
|
+
])
|
|
40
|
+
expect(await texts({ text: { in: ['ENGINES', 'nope'], mode } })).toEqual([
|
|
41
|
+
'Engines',
|
|
42
|
+
])
|
|
43
|
+
expect(
|
|
44
|
+
await texts({ text: { notIn: ['engines', 'émile'], mode } }),
|
|
45
|
+
).toEqual(['Analysis', 'Compilers'])
|
|
46
|
+
expect(await texts({ text: { contains: 'GIN', mode } })).toEqual([
|
|
47
|
+
'Engines',
|
|
48
|
+
])
|
|
49
|
+
expect(await texts({ text: { startsWith: 'c', mode } })).toEqual([
|
|
50
|
+
'Compilers',
|
|
51
|
+
])
|
|
52
|
+
expect(await texts({ text: { endsWith: 'SIS', mode } })).toEqual([
|
|
53
|
+
'Analysis',
|
|
54
|
+
])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('folds letters beyond ASCII', async () => {
|
|
58
|
+
const mode = 'insensitive'
|
|
59
|
+
expect(await texts({ text: { startsWith: 'é', mode } })).toEqual([
|
|
60
|
+
'Émile',
|
|
61
|
+
])
|
|
62
|
+
expect(await texts({ text: { equals: 'ÉMILE', mode } })).toEqual([
|
|
63
|
+
'Émile',
|
|
64
|
+
])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('keeps pattern characters as they are', async () => {
|
|
68
|
+
const mode = 'insensitive'
|
|
69
|
+
expect(await texts({ text: { equals: 'analysi_', mode } })).toEqual([])
|
|
70
|
+
expect(await texts({ text: { contains: '%', mode } })).toEqual([])
|
|
71
|
+
expect(await texts({ text: { in: [], mode } })).toEqual([])
|
|
72
|
+
// `mode` alone filters nothing, as in Prisma.
|
|
73
|
+
expect(await texts({ text: { mode } })).toHaveLength(5)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
}
|
package/src/counts.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// Counts and sorts through relations in the contract (D-134): `_count` at
|
|
2
|
+
// any depth and filtered, a sort through `one` relations or by a count, on
|
|
3
|
+
// the rows the caller reads, a cursor kept exact.
|
|
4
|
+
import type { Adapter, Identity } from 'ultimabase'
|
|
5
|
+
import { defineModel, master } from 'ultimabase'
|
|
6
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
7
|
+
import { suiteUser } from './as-user.ts'
|
|
8
|
+
import { started } from './suite.ts'
|
|
9
|
+
|
|
10
|
+
const M: { readonly as: Identity } = { as: master }
|
|
11
|
+
const user = { as: suiteUser('00000000-0000-4000-8000-000000000000') }
|
|
12
|
+
const open = () => true
|
|
13
|
+
|
|
14
|
+
const Firm = defineModel({
|
|
15
|
+
name: 'Firm',
|
|
16
|
+
fields: (f) => ({ name: f.string(), authors: f.many(() => Author) }),
|
|
17
|
+
}).with({ access: { read: open } })
|
|
18
|
+
|
|
19
|
+
/** A user reads the authors who are not retired. */
|
|
20
|
+
const Author = defineModel({
|
|
21
|
+
name: 'Author',
|
|
22
|
+
fields: (f) => ({
|
|
23
|
+
name: f.string(),
|
|
24
|
+
retired: f.boolean({ default: false }),
|
|
25
|
+
firm: f.one(() => Firm, { optional: true }),
|
|
26
|
+
articles: f.many(() => Article),
|
|
27
|
+
}),
|
|
28
|
+
}).with({ access: { read: ({ where }) => where({ retired: false }) } })
|
|
29
|
+
|
|
30
|
+
const Article = defineModel({
|
|
31
|
+
name: 'Article',
|
|
32
|
+
fields: (f) => ({
|
|
33
|
+
title: f.string({ unique: true }),
|
|
34
|
+
author: f.one(() => Author, { optional: true }),
|
|
35
|
+
remarks: f.many(() => Remark),
|
|
36
|
+
labels: f.many(() => Label),
|
|
37
|
+
}),
|
|
38
|
+
}).with({ access: { read: open } })
|
|
39
|
+
|
|
40
|
+
/** A user reads the remarks that are not hidden. */
|
|
41
|
+
const Remark = defineModel({
|
|
42
|
+
name: 'Remark',
|
|
43
|
+
fields: (f) => ({
|
|
44
|
+
body: f.string(),
|
|
45
|
+
hidden: f.boolean({ default: false }),
|
|
46
|
+
article: f.one(() => Article, { onDelete: 'cascade' }),
|
|
47
|
+
}),
|
|
48
|
+
}).with({ access: { read: ({ where }) => where({ hidden: false }) } })
|
|
49
|
+
|
|
50
|
+
const Label = defineModel({
|
|
51
|
+
name: 'Label',
|
|
52
|
+
fields: (f) => ({ text: f.string(), articles: f.many(() => Article) }),
|
|
53
|
+
}).with({ access: { read: open } })
|
|
54
|
+
|
|
55
|
+
const titles = (rows: readonly { readonly title: string }[]): string[] =>
|
|
56
|
+
rows.map((row) => row.title)
|
|
57
|
+
|
|
58
|
+
const connect = (id: string) => ({ connect: { id } })
|
|
59
|
+
|
|
60
|
+
/** The articles' titles, by their author's firm name, then their title. */
|
|
61
|
+
const byFirm = async (direction: 'asc' | 'desc', as = M) =>
|
|
62
|
+
titles(
|
|
63
|
+
await Article.findMany(
|
|
64
|
+
{
|
|
65
|
+
orderBy: [{ author: { firm: { name: direction } } }, { title: 'asc' }],
|
|
66
|
+
},
|
|
67
|
+
as,
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
export function counts(create: () => Adapter): void {
|
|
72
|
+
describe('counts and sorts through relations', () => {
|
|
73
|
+
started(create, [Firm, Author, Article, Remark, Label])
|
|
74
|
+
|
|
75
|
+
let ada = ''
|
|
76
|
+
beforeEach(async () => {
|
|
77
|
+
const zeta = await Firm.create({ data: { name: 'Zeta' } }, M)
|
|
78
|
+
const acme = await Firm.create({ data: { name: 'Acme' } }, M)
|
|
79
|
+
ada = (
|
|
80
|
+
await Author.create(
|
|
81
|
+
{ data: { name: 'Ada', firm: connect(zeta.id) } },
|
|
82
|
+
M,
|
|
83
|
+
)
|
|
84
|
+
).id
|
|
85
|
+
const bob = await Author.create(
|
|
86
|
+
{ data: { name: 'Bob', retired: true, firm: connect(acme.id) } },
|
|
87
|
+
M,
|
|
88
|
+
)
|
|
89
|
+
await Author.create({ data: { name: 'Cy' } }, M)
|
|
90
|
+
const ts = await Label.create({ data: { text: 'ts' } }, M)
|
|
91
|
+
const go = await Label.create({ data: { text: 'go' } }, M)
|
|
92
|
+
const article = async (
|
|
93
|
+
title: string,
|
|
94
|
+
author: string | undefined,
|
|
95
|
+
remarks: readonly { body: string; hidden?: boolean }[],
|
|
96
|
+
labels: readonly string[],
|
|
97
|
+
) =>
|
|
98
|
+
Article.create(
|
|
99
|
+
{
|
|
100
|
+
data: {
|
|
101
|
+
title,
|
|
102
|
+
...(author === undefined ? {} : { author: connect(author) }),
|
|
103
|
+
remarks: { create: remarks },
|
|
104
|
+
labels: { connect: labels.map((id) => ({ id })) },
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
M,
|
|
108
|
+
)
|
|
109
|
+
const hidden = true
|
|
110
|
+
await article(
|
|
111
|
+
'One',
|
|
112
|
+
ada,
|
|
113
|
+
[{ body: 'r1' }, { body: 'r2', hidden }, { body: 'x', hidden }],
|
|
114
|
+
[ts.id, go.id],
|
|
115
|
+
)
|
|
116
|
+
await article('Two', bob.id, [], [ts.id])
|
|
117
|
+
await article('Three', ada, [{ body: 'r3' }, { body: 'r4' }], [])
|
|
118
|
+
await article('Four', undefined, [], [])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('counts every many relation, or the ones it names, filtered', async () => {
|
|
122
|
+
const authors = await Author.findMany(
|
|
123
|
+
{ orderBy: { name: 'asc' }, include: { _count: true } },
|
|
124
|
+
M,
|
|
125
|
+
)
|
|
126
|
+
expect(authors.map((row) => [row.name, row._count])).toEqual([
|
|
127
|
+
['Ada', { articles: 2 }],
|
|
128
|
+
['Bob', { articles: 1 }],
|
|
129
|
+
['Cy', { articles: 0 }],
|
|
130
|
+
])
|
|
131
|
+
const articles = await Article.findMany(
|
|
132
|
+
{
|
|
133
|
+
orderBy: { title: 'asc' },
|
|
134
|
+
select: {
|
|
135
|
+
title: true,
|
|
136
|
+
_count: {
|
|
137
|
+
select: {
|
|
138
|
+
remarks: { where: { body: { startsWith: 'r' } } },
|
|
139
|
+
labels: true,
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
M,
|
|
145
|
+
)
|
|
146
|
+
expect(articles).toEqual([
|
|
147
|
+
{ title: 'Four', _count: { remarks: 0, labels: 0 } },
|
|
148
|
+
{ title: 'One', _count: { remarks: 2, labels: 2 } },
|
|
149
|
+
{ title: 'Three', _count: { remarks: 2, labels: 0 } },
|
|
150
|
+
{ title: 'Two', _count: { remarks: 0, labels: 1 } },
|
|
151
|
+
])
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('counts at any depth, the rows the caller reads', async () => {
|
|
155
|
+
const args = {
|
|
156
|
+
where: { id: ada },
|
|
157
|
+
include: {
|
|
158
|
+
articles: {
|
|
159
|
+
orderBy: { title: 'asc' },
|
|
160
|
+
include: { _count: { select: { remarks: true } } },
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
} as const
|
|
164
|
+
const counted = async (as: typeof M) =>
|
|
165
|
+
(await Author.findUnique(args, as))?.articles.map((row) => [
|
|
166
|
+
row.title,
|
|
167
|
+
row._count.remarks,
|
|
168
|
+
])
|
|
169
|
+
expect(await counted(M)).toEqual([
|
|
170
|
+
['One', 3],
|
|
171
|
+
['Three', 2],
|
|
172
|
+
])
|
|
173
|
+
expect(await counted(user)).toEqual([
|
|
174
|
+
['One', 1],
|
|
175
|
+
['Three', 2],
|
|
176
|
+
])
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('sorts through one relations, an absent row last ascending', async () => {
|
|
180
|
+
expect(await byFirm('asc')).toEqual(['Two', 'One', 'Three', 'Four'])
|
|
181
|
+
expect(await byFirm('desc')).toEqual(['Four', 'One', 'Three', 'Two'])
|
|
182
|
+
// Bob, whom a user does not read, sorts as an absent author.
|
|
183
|
+
expect(await byFirm('asc', user)).toEqual(['One', 'Three', 'Four', 'Two'])
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('sorts by a count of the rows the caller reads', async () => {
|
|
187
|
+
const by = async (as: typeof M) =>
|
|
188
|
+
titles(
|
|
189
|
+
await Article.findMany(
|
|
190
|
+
{
|
|
191
|
+
where: { remarks: { some: {} } },
|
|
192
|
+
orderBy: { remarks: { _count: 'desc' } },
|
|
193
|
+
},
|
|
194
|
+
as,
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
expect(await by(M)).toEqual(['One', 'Three'])
|
|
198
|
+
expect(await by(user)).toEqual(['Three', 'One'])
|
|
199
|
+
const authors = await Author.findMany(
|
|
200
|
+
{ orderBy: [{ articles: { _count: 'desc' } }, { name: 'asc' }] },
|
|
201
|
+
M,
|
|
202
|
+
)
|
|
203
|
+
expect(authors.map((row) => row.name)).toEqual(['Ada', 'Bob', 'Cy'])
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('pages exactly from a cursor through a relation sort', async () => {
|
|
207
|
+
const orderBy = [
|
|
208
|
+
{ author: { name: 'desc' } },
|
|
209
|
+
{ remarks: { _count: 'asc' } },
|
|
210
|
+
] as const
|
|
211
|
+
const all = titles(await Article.findMany({ orderBy }, M))
|
|
212
|
+
expect(all).toEqual(['Four', 'Two', 'Three', 'One'])
|
|
213
|
+
const rows = await Article.findMany({ orderBy }, M)
|
|
214
|
+
for (const [at, row] of rows.entries()) {
|
|
215
|
+
const cursor = { id: row.id }
|
|
216
|
+
const after = await Article.findMany({ orderBy, cursor, take: 2 }, M)
|
|
217
|
+
expect(titles(after)).toEqual(all.slice(at, at + 2))
|
|
218
|
+
const before = await Article.findMany({ orderBy, cursor, take: -2 }, M)
|
|
219
|
+
expect(titles(before)).toEqual(all.slice(Math.max(0, at - 1), at + 1))
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
}
|
package/src/days.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Calendar days in the contract (D-150): the string '2026-07-31' as written,
|
|
2
|
+
// filtered, sorted, grouped and aggregated in the calendar's order.
|
|
3
|
+
import type { Adapter, Identity } 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: { readonly as: Identity } = { as: master }
|
|
9
|
+
|
|
10
|
+
const Shift = defineModel({
|
|
11
|
+
name: 'Shift',
|
|
12
|
+
fields: (f) => ({
|
|
13
|
+
on: f.date(),
|
|
14
|
+
off: f.date({ optional: true, unique: true }),
|
|
15
|
+
team: f.string(),
|
|
16
|
+
}),
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const days = (rows: readonly { readonly on: string }[]) =>
|
|
20
|
+
rows.map((row) => row.on)
|
|
21
|
+
|
|
22
|
+
const rows = [
|
|
23
|
+
{ on: '2026-07-31', team: 'a' },
|
|
24
|
+
{ on: '0001-01-01', team: 'b', off: '2026-12-25' },
|
|
25
|
+
{ on: '2024-02-29', team: 'a' },
|
|
26
|
+
{ on: '9999-12-31', team: 'b' },
|
|
27
|
+
{ on: '2026-07-31', team: 'b' },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
export function calendarDays(create: () => Adapter): void {
|
|
31
|
+
describe('calendar days', () => {
|
|
32
|
+
started(create, [Shift])
|
|
33
|
+
|
|
34
|
+
it('reads each day as written, and finds it unique', async () => {
|
|
35
|
+
const created = await Shift.create(
|
|
36
|
+
{ data: { on: '2026-07-31', team: 'a', off: '2026-08-01' } },
|
|
37
|
+
M,
|
|
38
|
+
)
|
|
39
|
+
expect(created).toMatchObject({ on: '2026-07-31', off: '2026-08-01' })
|
|
40
|
+
const updated = await Shift.update(
|
|
41
|
+
{ where: { id: created.id }, data: { on: '0001-01-01', off: null } },
|
|
42
|
+
M,
|
|
43
|
+
)
|
|
44
|
+
expect(updated).toMatchObject({ on: '0001-01-01', off: null })
|
|
45
|
+
await Shift.update(
|
|
46
|
+
{ where: { id: created.id }, data: { off: '2024-02-29' } },
|
|
47
|
+
M,
|
|
48
|
+
)
|
|
49
|
+
const found = await Shift.findUnique({ where: { off: '2024-02-29' } }, M)
|
|
50
|
+
expect(found).toMatchObject({ id: created.id, on: '0001-01-01' })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('filters and sorts in the calendar order', async () => {
|
|
54
|
+
await Shift.createMany({ data: rows }, M)
|
|
55
|
+
const sorted = await Shift.findMany(
|
|
56
|
+
{ orderBy: [{ on: 'desc' }, { team: 'asc' }] },
|
|
57
|
+
M,
|
|
58
|
+
)
|
|
59
|
+
expect(days(sorted)).toEqual([
|
|
60
|
+
'9999-12-31',
|
|
61
|
+
'2026-07-31',
|
|
62
|
+
'2026-07-31',
|
|
63
|
+
'2024-02-29',
|
|
64
|
+
'0001-01-01',
|
|
65
|
+
])
|
|
66
|
+
const where = { on: { gt: '0001-01-01', lte: '2026-07-31' } }
|
|
67
|
+
expect(await Shift.count({ where }, M)).toBe(3)
|
|
68
|
+
const listed = { on: { in: ['2024-02-29', '9999-12-31'] } }
|
|
69
|
+
expect(await Shift.count({ where: listed }, M)).toBe(2)
|
|
70
|
+
const others = { on: { notIn: ['2026-07-31'], not: '0001-01-01' } }
|
|
71
|
+
expect(await Shift.count({ where: others }, M)).toBe(2)
|
|
72
|
+
expect(await Shift.count({ where: { on: '2026-07-31' } }, M)).toBe(2)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('groups, picks one row per day and takes the least and the most', async () => {
|
|
76
|
+
await Shift.createMany({ data: rows }, M)
|
|
77
|
+
const groups = await Shift.groupBy(
|
|
78
|
+
{ by: ['on'], _count: true, orderBy: { on: 'asc' } },
|
|
79
|
+
M,
|
|
80
|
+
)
|
|
81
|
+
expect(groups).toStrictEqual([
|
|
82
|
+
{ on: '0001-01-01', _count: 1 },
|
|
83
|
+
{ on: '2024-02-29', _count: 1 },
|
|
84
|
+
{ on: '2026-07-31', _count: 2 },
|
|
85
|
+
{ on: '9999-12-31', _count: 1 },
|
|
86
|
+
])
|
|
87
|
+
const distinct = await Shift.findMany(
|
|
88
|
+
{ distinct: ['on'], orderBy: { on: 'asc' } },
|
|
89
|
+
M,
|
|
90
|
+
)
|
|
91
|
+
expect(days(distinct)).toEqual([
|
|
92
|
+
'0001-01-01',
|
|
93
|
+
'2024-02-29',
|
|
94
|
+
'2026-07-31',
|
|
95
|
+
'9999-12-31',
|
|
96
|
+
])
|
|
97
|
+
const result = await Shift.aggregate(
|
|
98
|
+
{ _min: { on: true, off: true }, _max: { on: true, off: true } },
|
|
99
|
+
M,
|
|
100
|
+
)
|
|
101
|
+
expect(result).toEqual({
|
|
102
|
+
_min: { on: '0001-01-01', off: '2026-12-25' },
|
|
103
|
+
_max: { on: '9999-12-31', off: '2026-12-25' },
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
}
|
package/src/decimals.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Decimals in the contract (D-145): exact strings at their field's scale,
|
|
2
|
+
// filtered, sorted and aggregated by value, beyond a double's digits.
|
|
3
|
+
import type { Adapter, Identity } 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: { readonly as: Identity } = { as: master }
|
|
9
|
+
|
|
10
|
+
const Price = defineModel({
|
|
11
|
+
name: 'Price',
|
|
12
|
+
fields: (f) => ({
|
|
13
|
+
amount: f.decimal({ precision: 6, scale: 2 }),
|
|
14
|
+
code: f.decimal({ precision: 4, scale: 1, optional: true, unique: true }),
|
|
15
|
+
exact: f.decimal({ precision: 30, scale: 10, optional: true }),
|
|
16
|
+
}),
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const amounts = (rows: readonly { readonly amount: string }[]) =>
|
|
20
|
+
rows.map((row) => row.amount)
|
|
21
|
+
|
|
22
|
+
export function decimals(create: () => Adapter): void {
|
|
23
|
+
describe('decimals', () => {
|
|
24
|
+
started(create, [Price])
|
|
25
|
+
|
|
26
|
+
it('stores each value at its scale, beyond a double', async () => {
|
|
27
|
+
const exact = '12345678901234567890.0123456789'
|
|
28
|
+
const created = await Price.create(
|
|
29
|
+
{ data: { amount: '012.5', code: '-0', exact } },
|
|
30
|
+
M,
|
|
31
|
+
)
|
|
32
|
+
expect(created).toMatchObject({ amount: '12.50', code: '0.0', exact })
|
|
33
|
+
const updated = await Price.update(
|
|
34
|
+
{ where: { id: created.id }, data: { amount: '7.500', exact: null } },
|
|
35
|
+
M,
|
|
36
|
+
)
|
|
37
|
+
expect(updated).toMatchObject({ amount: '7.50', exact: null })
|
|
38
|
+
const found = await Price.findUnique({ where: { code: '0' } }, M)
|
|
39
|
+
expect(found?.id).toBe(created.id)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('filters and sorts by value, not by text', async () => {
|
|
43
|
+
const nine = await Price.create({ data: { amount: '9.5' } }, M)
|
|
44
|
+
await Price.createMany(
|
|
45
|
+
{ data: ['10', '-2.25', '100'].map((amount) => ({ amount })) },
|
|
46
|
+
M,
|
|
47
|
+
)
|
|
48
|
+
const sorted = await Price.findMany({ orderBy: { amount: 'asc' } }, M)
|
|
49
|
+
expect(amounts(sorted)).toEqual(['-2.25', '9.50', '10.00', '100.00'])
|
|
50
|
+
const where = { amount: { gt: '9.5', lte: '100.00' } }
|
|
51
|
+
expect(amounts(await Price.findMany({ where }, M))).toEqual([
|
|
52
|
+
'10.00',
|
|
53
|
+
'100.00',
|
|
54
|
+
])
|
|
55
|
+
const listed = { amount: { in: ['10', '-2.250'] } }
|
|
56
|
+
expect(await Price.count({ where: listed }, M)).toBe(2)
|
|
57
|
+
const after = await Price.findMany(
|
|
58
|
+
{
|
|
59
|
+
orderBy: { amount: 'desc' },
|
|
60
|
+
cursor: { id: nine.id },
|
|
61
|
+
skip: 1,
|
|
62
|
+
},
|
|
63
|
+
M,
|
|
64
|
+
)
|
|
65
|
+
expect(amounts(after)).toEqual(['-2.25'])
|
|
66
|
+
const before = await Price.findMany(
|
|
67
|
+
{ orderBy: { amount: 'asc' }, cursor: { id: nine.id } },
|
|
68
|
+
M,
|
|
69
|
+
)
|
|
70
|
+
expect(amounts(before)).toEqual(['9.50', '10.00', '100.00'])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('sums exactly, at the scale, and compares by value', async () => {
|
|
74
|
+
await Price.createMany(
|
|
75
|
+
{
|
|
76
|
+
data: [
|
|
77
|
+
{ amount: '0.10', exact: '0.1' },
|
|
78
|
+
{ amount: '0.20', exact: '99999999999999999999.9999999999' },
|
|
79
|
+
{ amount: '-10' },
|
|
80
|
+
{ amount: '9' },
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
M,
|
|
84
|
+
)
|
|
85
|
+
const result = await Price.aggregate(
|
|
86
|
+
{
|
|
87
|
+
_sum: { amount: true, exact: true },
|
|
88
|
+
_avg: { amount: true },
|
|
89
|
+
_min: { amount: true },
|
|
90
|
+
_max: { amount: true, code: true },
|
|
91
|
+
},
|
|
92
|
+
M,
|
|
93
|
+
)
|
|
94
|
+
expect(result).toEqual({
|
|
95
|
+
_sum: { amount: '-0.70', exact: '100000000000000000000.0999999999' },
|
|
96
|
+
_avg: { amount: -0.175 },
|
|
97
|
+
_min: { amount: '-10.00' },
|
|
98
|
+
_max: { amount: '9.00', code: null },
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
}
|
package/src/distincts.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// `distinct` in the contract (D-147): of the rows with the same keys, the
|
|
2
|
+
// first in `orderBy`'s order, then `skip` and `take`; rows with no value
|
|
3
|
+
// share a key, and a relation the caller cannot read is no relation.
|
|
4
|
+
import type { Adapter, Identity } from 'ultimabase'
|
|
5
|
+
import { defineModel, master } from 'ultimabase'
|
|
6
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
7
|
+
import { suiteUser } from './as-user.ts'
|
|
8
|
+
import { started } from './suite.ts'
|
|
9
|
+
|
|
10
|
+
const M: { readonly as: Identity } = { as: master }
|
|
11
|
+
const user = { as: suiteUser('00000000-0000-4000-8000-000000000000') }
|
|
12
|
+
|
|
13
|
+
/** A user reads the clubs that are not hidden. */
|
|
14
|
+
const Club = defineModel({
|
|
15
|
+
name: 'Club',
|
|
16
|
+
fields: (f) => ({
|
|
17
|
+
hidden: f.boolean({ default: false }),
|
|
18
|
+
visits: f.many(() => Visit),
|
|
19
|
+
}),
|
|
20
|
+
}).with({ access: { read: ({ where }) => where({ hidden: false }) } })
|
|
21
|
+
|
|
22
|
+
const Visit = defineModel({
|
|
23
|
+
name: 'Visit',
|
|
24
|
+
fields: (f) => ({
|
|
25
|
+
score: f.number({ integer: true }),
|
|
26
|
+
region: f.string({ optional: true }),
|
|
27
|
+
club: f.one(() => Club, { optional: true }),
|
|
28
|
+
}),
|
|
29
|
+
}).with({ access: { read: () => true } })
|
|
30
|
+
|
|
31
|
+
const select = { score: true } as const
|
|
32
|
+
const scores = (rows: readonly { readonly score: number }[]): number[] =>
|
|
33
|
+
rows.map((row) => row.score)
|
|
34
|
+
|
|
35
|
+
export function distincts(create: () => Adapter): void {
|
|
36
|
+
describe('distincts', () => {
|
|
37
|
+
started(create, [Club, Visit])
|
|
38
|
+
|
|
39
|
+
beforeEach(async () => {
|
|
40
|
+
const red = (await Club.create({ data: {} }, M)).id
|
|
41
|
+
const blue = (await Club.create({ data: { hidden: true } }, M)).id
|
|
42
|
+
const rows = [
|
|
43
|
+
{ score: 1, region: 'n', club: red },
|
|
44
|
+
{ score: 2, region: 'n', club: blue },
|
|
45
|
+
{ score: 3, region: 's', club: red },
|
|
46
|
+
{ score: 4 },
|
|
47
|
+
{ score: 5, region: 's', club: blue },
|
|
48
|
+
{ score: 6, club: red },
|
|
49
|
+
]
|
|
50
|
+
for (const { club, ...row } of rows) {
|
|
51
|
+
const data =
|
|
52
|
+
club === undefined ? row : { ...row, club: { connect: { id: club } } }
|
|
53
|
+
await Visit.create({ data }, M)
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('keeps the first row of each key in the order, the rows with no value together', async () => {
|
|
58
|
+
const first = await Visit.findMany(
|
|
59
|
+
{ distinct: ['region'], orderBy: { score: 'asc' }, select },
|
|
60
|
+
M,
|
|
61
|
+
)
|
|
62
|
+
expect(scores(first)).toStrictEqual([1, 3, 4])
|
|
63
|
+
const last = await Visit.findMany(
|
|
64
|
+
{ distinct: ['region'], orderBy: { score: 'desc' }, select },
|
|
65
|
+
M,
|
|
66
|
+
)
|
|
67
|
+
expect(scores(last)).toStrictEqual([6, 5, 2])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('filters before it keeps, and pages after', async () => {
|
|
71
|
+
const filtered = await Visit.findMany(
|
|
72
|
+
{
|
|
73
|
+
distinct: ['region'],
|
|
74
|
+
where: { score: { gte: 3 } },
|
|
75
|
+
orderBy: { score: 'asc' },
|
|
76
|
+
select,
|
|
77
|
+
},
|
|
78
|
+
M,
|
|
79
|
+
)
|
|
80
|
+
expect(scores(filtered)).toStrictEqual([3, 4])
|
|
81
|
+
const args = {
|
|
82
|
+
distinct: ['region'],
|
|
83
|
+
orderBy: { score: 'desc' },
|
|
84
|
+
select,
|
|
85
|
+
} as const
|
|
86
|
+
const paged = await Visit.findMany({ ...args, skip: 1, take: 1 }, M)
|
|
87
|
+
expect(scores(paged)).toStrictEqual([5])
|
|
88
|
+
const back = await Visit.findMany({ ...args, take: -1 }, M)
|
|
89
|
+
expect(scores(back)).toStrictEqual([2])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('compares a relation by its target, none when unread', async () => {
|
|
93
|
+
const args = {
|
|
94
|
+
distinct: ['club'],
|
|
95
|
+
orderBy: { score: 'asc' },
|
|
96
|
+
select,
|
|
97
|
+
} as const
|
|
98
|
+
expect(scores(await Visit.findMany(args, M))).toStrictEqual([1, 2, 4])
|
|
99
|
+
expect(scores(await Visit.findMany(args, user))).toStrictEqual([1, 2])
|
|
100
|
+
const both = await Visit.findMany(
|
|
101
|
+
{ ...args, distinct: ['region', 'club'] },
|
|
102
|
+
M,
|
|
103
|
+
)
|
|
104
|
+
expect(scores(both)).toStrictEqual([1, 2, 3, 4, 5, 6])
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
}
|