@opensaas/stack-core 0.26.0 → 0.27.0
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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +48 -12
- package/dist/access/access-filter.d.ts +17 -0
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +39 -0
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +1 -1
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +1 -1
- package/dist/access/index.js.map +1 -1
- package/dist/access/multi-column-read-write.test.js.map +1 -1
- package/dist/config/label.d.ts +22 -0
- package/dist/config/label.d.ts.map +1 -0
- package/dist/config/label.js +48 -0
- package/dist/config/label.js.map +1 -0
- package/dist/config/plugin-engine.js.map +1 -1
- package/dist/config/types.d.ts +19 -5
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts +7 -0
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +30 -1
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/transaction-boundary.d.ts.map +1 -1
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/calendar-day.test.js +133 -7
- package/dist/fields/calendar-day.test.js.map +1 -1
- package/dist/fields/format-prisma-default.js.map +1 -1
- package/dist/fields/index.d.ts +7 -3
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +37 -8
- package/dist/fields/index.js.map +1 -1
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/relationship-options.d.ts +26 -0
- package/dist/query/relationship-options.d.ts.map +1 -0
- package/dist/query/relationship-options.js +43 -0
- package/dist/query/relationship-options.js.map +1 -0
- package/dist/query/relationship-options.test.d.ts +2 -0
- package/dist/query/relationship-options.test.d.ts.map +1 -0
- package/dist/query/relationship-options.test.js +121 -0
- package/dist/query/relationship-options.test.js.map +1 -0
- package/dist/utils/password.d.ts.map +1 -1
- package/dist/utils/password.js.map +1 -1
- package/dist/validation/schema.js.map +1 -1
- package/package.json +6 -5
- package/src/access/access-filter.ts +54 -0
- package/src/access/index.ts +5 -1
- package/src/config/label.ts +58 -0
- package/src/config/types.ts +20 -13
- package/src/context/index.ts +45 -5
- package/src/context/write-pipeline.ts +1 -2
- package/src/fields/calendar-day.test.ts +158 -7
- package/src/fields/index.ts +39 -11
- package/src/hooks/index.ts +1 -2
- package/src/index.ts +11 -0
- package/src/query/relationship-options.test.ts +157 -0
- package/src/query/relationship-options.ts +70 -0
- package/tests/context.test.ts +220 -0
- package/tests/label.test.ts +135 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { getRelationshipOptions } from './relationship-options.js'
|
|
3
|
+
import type { QueryRunnerContext } from './index.js'
|
|
4
|
+
import type { OpenSaasConfig } from '../config/types.js'
|
|
5
|
+
|
|
6
|
+
function makeDelegate(rows: Array<Record<string, unknown>>) {
|
|
7
|
+
return {
|
|
8
|
+
findMany: vi.fn(async (_args?: unknown) => rows),
|
|
9
|
+
findFirst: vi.fn(async (_args?: unknown) => rows[0] ?? null),
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function makeContext(
|
|
14
|
+
delegates: Record<string, ReturnType<typeof makeDelegate>>,
|
|
15
|
+
): QueryRunnerContext {
|
|
16
|
+
return { db: delegates }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const authors = [
|
|
20
|
+
{ id: 'a1', name: 'Ada Lovelace' },
|
|
21
|
+
{ id: 'a2', name: 'Alan Turing' },
|
|
22
|
+
{ id: 'a3', name: 'Grace Hopper' },
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
function makeConfig(): OpenSaasConfig {
|
|
26
|
+
return {
|
|
27
|
+
db: { provider: 'sqlite', url: 'file:./test.db' },
|
|
28
|
+
lists: {
|
|
29
|
+
Author: {
|
|
30
|
+
fields: {
|
|
31
|
+
name: { type: 'text' },
|
|
32
|
+
},
|
|
33
|
+
access: { operation: { query: () => true } },
|
|
34
|
+
},
|
|
35
|
+
NumericLabel: {
|
|
36
|
+
fields: {
|
|
37
|
+
rank: { type: 'integer' },
|
|
38
|
+
},
|
|
39
|
+
ui: { labelField: 'rank' },
|
|
40
|
+
access: { operation: { query: () => true } },
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
} as unknown as OpenSaasConfig
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('getRelationshipOptions', () => {
|
|
47
|
+
it('returns { id, label }[] via a scalar-only fragment with no nested include', async () => {
|
|
48
|
+
const delegate = makeDelegate(authors)
|
|
49
|
+
const context = makeContext({ author: delegate })
|
|
50
|
+
const config = makeConfig()
|
|
51
|
+
|
|
52
|
+
const result = await getRelationshipOptions(context, config, 'Author', {})
|
|
53
|
+
|
|
54
|
+
expect(result).toEqual([
|
|
55
|
+
{ id: 'a1', label: 'Ada Lovelace' },
|
|
56
|
+
{ id: 'a2', label: 'Alan Turing' },
|
|
57
|
+
{ id: 'a3', label: 'Grace Hopper' },
|
|
58
|
+
])
|
|
59
|
+
|
|
60
|
+
const call = delegate.findMany.mock.calls[0][0] as Record<string, unknown>
|
|
61
|
+
expect(call.include).toBeUndefined()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('bounds the result by take', async () => {
|
|
65
|
+
const delegate = makeDelegate(authors.slice(0, 2))
|
|
66
|
+
const context = makeContext({ author: delegate })
|
|
67
|
+
const config = makeConfig()
|
|
68
|
+
|
|
69
|
+
await getRelationshipOptions(context, config, 'Author', { take: 2 })
|
|
70
|
+
|
|
71
|
+
expect(delegate.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 2 }))
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('orders by the label field ascending and filters via contains on a text label field', async () => {
|
|
75
|
+
const delegate = makeDelegate([authors[0]])
|
|
76
|
+
const context = makeContext({ author: delegate })
|
|
77
|
+
const config = makeConfig()
|
|
78
|
+
|
|
79
|
+
await getRelationshipOptions(context, config, 'Author', { search: 'Ada' })
|
|
80
|
+
|
|
81
|
+
expect(delegate.findMany).toHaveBeenCalledWith(
|
|
82
|
+
expect.objectContaining({
|
|
83
|
+
where: { name: { contains: 'Ada' } },
|
|
84
|
+
orderBy: { name: 'asc' },
|
|
85
|
+
}),
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('does not filter (first-N) when the label field is not a text field', async () => {
|
|
90
|
+
const rows = [{ id: 'n1', rank: 1 }]
|
|
91
|
+
const delegate = makeDelegate(rows)
|
|
92
|
+
const context = makeContext({ numericLabel: delegate })
|
|
93
|
+
const config = makeConfig()
|
|
94
|
+
|
|
95
|
+
await getRelationshipOptions(context, config, 'NumericLabel', { search: '1' })
|
|
96
|
+
|
|
97
|
+
const call = delegate.findMany.mock.calls[0][0] as Record<string, unknown>
|
|
98
|
+
expect(call.where).toBeUndefined()
|
|
99
|
+
expect(call.orderBy).toEqual({ rank: 'asc' })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('unions currently-selected ids even when beyond take / not matching search', async () => {
|
|
103
|
+
// The bounded/search-scoped query only returns a1 (mimicking take:1 + search).
|
|
104
|
+
const primaryDelegate = makeDelegate([authors[0]])
|
|
105
|
+
const context = makeContext({ author: primaryDelegate })
|
|
106
|
+
const config = makeConfig()
|
|
107
|
+
|
|
108
|
+
// The selected-ids query (a separate findMany call) resolves a3, which fell
|
|
109
|
+
// outside the primary window.
|
|
110
|
+
primaryDelegate.findMany.mockImplementationOnce(async () => [authors[0]])
|
|
111
|
+
primaryDelegate.findMany.mockImplementationOnce(async () => [authors[2]])
|
|
112
|
+
|
|
113
|
+
const result = await getRelationshipOptions(context, config, 'Author', {
|
|
114
|
+
take: 1,
|
|
115
|
+
selectedIds: ['a3'],
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
expect(result).toEqual([
|
|
119
|
+
{ id: 'a1', label: 'Ada Lovelace' },
|
|
120
|
+
{ id: 'a3', label: 'Grace Hopper' },
|
|
121
|
+
])
|
|
122
|
+
|
|
123
|
+
const selectedCall = primaryDelegate.findMany.mock.calls[1][0] as Record<string, unknown>
|
|
124
|
+
expect(selectedCall.where).toEqual({ id: { in: ['a3'] } })
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('does not re-query when the selected id is already within the primary window', async () => {
|
|
128
|
+
const delegate = makeDelegate([authors[0]])
|
|
129
|
+
const context = makeContext({ author: delegate })
|
|
130
|
+
const config = makeConfig()
|
|
131
|
+
|
|
132
|
+
await getRelationshipOptions(context, config, 'Author', { selectedIds: ['a1'] })
|
|
133
|
+
|
|
134
|
+
expect(delegate.findMany).toHaveBeenCalledTimes(1)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('returns [] when the related list query access is denied (findMany returns [])', async () => {
|
|
138
|
+
const delegate = makeDelegate([])
|
|
139
|
+
const context = makeContext({ author: delegate })
|
|
140
|
+
const config = makeConfig()
|
|
141
|
+
|
|
142
|
+
const result = await getRelationshipOptions(context, config, 'Author', {
|
|
143
|
+
selectedIds: ['a1'],
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
expect(result).toEqual([])
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('returns [] when the related list does not exist in config', async () => {
|
|
150
|
+
const context = makeContext({})
|
|
151
|
+
const config = makeConfig()
|
|
152
|
+
|
|
153
|
+
const result = await getRelationshipOptions(context, config, 'Missing', {})
|
|
154
|
+
|
|
155
|
+
expect(result).toEqual([])
|
|
156
|
+
})
|
|
157
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { OpenSaasConfig } from '../config/types.js'
|
|
2
|
+
import { getLabelFieldName, getItemLabel } from '../config/label.js'
|
|
3
|
+
import { defineFragment, runQuery, type QueryRunnerContext } from './index.js'
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TAKE = 50
|
|
6
|
+
|
|
7
|
+
export interface RelationshipOption {
|
|
8
|
+
id: string
|
|
9
|
+
label: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RelationshipOptionsArgs {
|
|
13
|
+
/** Filters the label field via `contains` when it is a text field. */
|
|
14
|
+
search?: string
|
|
15
|
+
/** Bounds the primary (search-scoped) window. @default 50 */
|
|
16
|
+
take?: number
|
|
17
|
+
/** Always unioned into the result, even outside the search/take window. */
|
|
18
|
+
selectedIds?: string[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Bounded, projected fetch of `{ id, label }` options for a relationship
|
|
23
|
+
* editor — the read primitive behind the `relationshipOptions` serverAction
|
|
24
|
+
* op. Selects only `id` and the resolved label field (via
|
|
25
|
+
* {@link getLabelFieldName}), so the fragment carries no relation keys and
|
|
26
|
+
* `buildIncludeWithAccessControl`'s depth-5 auto-include never runs.
|
|
27
|
+
*
|
|
28
|
+
* Operation-level `query` access on `relatedListKey` still applies — a denied
|
|
29
|
+
* list resolves to `[]` (via the underlying access-controlled `findMany`).
|
|
30
|
+
*/
|
|
31
|
+
export async function getRelationshipOptions(
|
|
32
|
+
context: QueryRunnerContext,
|
|
33
|
+
config: OpenSaasConfig,
|
|
34
|
+
relatedListKey: string,
|
|
35
|
+
args: RelationshipOptionsArgs = {},
|
|
36
|
+
): Promise<RelationshipOption[]> {
|
|
37
|
+
const relatedListConfig = config.lists[relatedListKey]
|
|
38
|
+
if (!relatedListConfig) return []
|
|
39
|
+
|
|
40
|
+
const labelField = getLabelFieldName(relatedListConfig)
|
|
41
|
+
const fragment = defineFragment<Record<string, unknown>>()({
|
|
42
|
+
id: true,
|
|
43
|
+
[labelField]: true,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const { search, take = DEFAULT_TAKE, selectedIds = [] } = args
|
|
47
|
+
const labelFieldConfig = relatedListConfig.fields[labelField] as { type?: string } | undefined
|
|
48
|
+
const where =
|
|
49
|
+
search && labelFieldConfig?.type === 'text' ? { [labelField]: { contains: search } } : undefined
|
|
50
|
+
|
|
51
|
+
const primary = await runQuery(context, relatedListKey, fragment, {
|
|
52
|
+
where,
|
|
53
|
+
orderBy: { [labelField]: 'asc' },
|
|
54
|
+
take,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const seenIds = new Set(primary.map((item) => (item as { id: string }).id))
|
|
58
|
+
const missingSelectedIds = selectedIds.filter((id) => !seenIds.has(id))
|
|
59
|
+
|
|
60
|
+
const selected = missingSelectedIds.length
|
|
61
|
+
? await runQuery(context, relatedListKey, fragment, {
|
|
62
|
+
where: { id: { in: missingSelectedIds } },
|
|
63
|
+
})
|
|
64
|
+
: []
|
|
65
|
+
|
|
66
|
+
return [...primary, ...selected].map((item) => ({
|
|
67
|
+
id: (item as { id: string }).id,
|
|
68
|
+
label: getItemLabel(relatedListConfig, item as Record<string, unknown>),
|
|
69
|
+
}))
|
|
70
|
+
}
|
package/tests/context.test.ts
CHANGED
|
@@ -57,6 +57,7 @@ describe('getContext', () => {
|
|
|
57
57
|
fields: {
|
|
58
58
|
title: { type: 'text' },
|
|
59
59
|
content: { type: 'text' },
|
|
60
|
+
author: { type: 'relationship', ref: 'User.posts' },
|
|
60
61
|
},
|
|
61
62
|
access: {
|
|
62
63
|
operation: {
|
|
@@ -200,6 +201,119 @@ describe('getContext', () => {
|
|
|
200
201
|
error: 'Database connection failed',
|
|
201
202
|
})
|
|
202
203
|
})
|
|
204
|
+
|
|
205
|
+
describe('relationshipOptions', () => {
|
|
206
|
+
it('returns { id, label }[] for the related list, unfiltered/unincluded', async () => {
|
|
207
|
+
mockPrisma.user.findMany.mockResolvedValue([
|
|
208
|
+
{ id: 'u1', name: 'Ada' },
|
|
209
|
+
{ id: 'u2', name: 'Alan' },
|
|
210
|
+
])
|
|
211
|
+
|
|
212
|
+
const context = await getContext(config, mockPrisma, null)
|
|
213
|
+
const result = await context.serverAction({
|
|
214
|
+
listKey: 'Post',
|
|
215
|
+
action: 'relationshipOptions',
|
|
216
|
+
field: 'author',
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
expect(result).toEqual({
|
|
220
|
+
success: true,
|
|
221
|
+
data: [
|
|
222
|
+
{ id: 'u1', label: 'Ada' },
|
|
223
|
+
{ id: 'u2', label: 'Alan' },
|
|
224
|
+
],
|
|
225
|
+
})
|
|
226
|
+
const call = mockPrisma.user.findMany.mock.calls[0][0]
|
|
227
|
+
expect(call.include).toBeUndefined()
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('bounds the query by take', async () => {
|
|
231
|
+
mockPrisma.user.findMany.mockResolvedValue([{ id: 'u1', name: 'Ada' }])
|
|
232
|
+
|
|
233
|
+
const context = await getContext(config, mockPrisma, null)
|
|
234
|
+
await context.serverAction({
|
|
235
|
+
listKey: 'Post',
|
|
236
|
+
action: 'relationshipOptions',
|
|
237
|
+
field: 'author',
|
|
238
|
+
take: 1,
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
expect(mockPrisma.user.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 1 }))
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('unions selectedIds beyond the take/search window', async () => {
|
|
245
|
+
mockPrisma.user.findMany.mockResolvedValueOnce([{ id: 'u1', name: 'Ada' }])
|
|
246
|
+
mockPrisma.user.findMany.mockResolvedValueOnce([{ id: 'u9', name: 'Zed' }])
|
|
247
|
+
|
|
248
|
+
const context = await getContext(config, mockPrisma, null)
|
|
249
|
+
const result = await context.serverAction({
|
|
250
|
+
listKey: 'Post',
|
|
251
|
+
action: 'relationshipOptions',
|
|
252
|
+
field: 'author',
|
|
253
|
+
take: 1,
|
|
254
|
+
selectedIds: ['u9'],
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
expect(result).toEqual({
|
|
258
|
+
success: true,
|
|
259
|
+
data: [
|
|
260
|
+
{ id: 'u1', label: 'Ada' },
|
|
261
|
+
{ id: 'u9', label: 'Zed' },
|
|
262
|
+
],
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('returns [] data when the related list query access is denied', async () => {
|
|
267
|
+
const deniedConfig: OpenSaasConfig = {
|
|
268
|
+
...config,
|
|
269
|
+
lists: {
|
|
270
|
+
...config.lists,
|
|
271
|
+
User: {
|
|
272
|
+
...config.lists.User,
|
|
273
|
+
access: { operation: { query: () => false } },
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
}
|
|
277
|
+
mockPrisma.user.findMany.mockResolvedValue([{ id: 'u1', name: 'Ada' }])
|
|
278
|
+
|
|
279
|
+
const context = await getContext(deniedConfig, mockPrisma, null)
|
|
280
|
+
const result = await context.serverAction({
|
|
281
|
+
listKey: 'Post',
|
|
282
|
+
action: 'relationshipOptions',
|
|
283
|
+
field: 'author',
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
expect(result).toEqual({ success: true, data: [] })
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('returns an error when the field is not a relationship field', async () => {
|
|
290
|
+
const context = await getContext(config, mockPrisma, null)
|
|
291
|
+
const result = await context.serverAction({
|
|
292
|
+
listKey: 'Post',
|
|
293
|
+
action: 'relationshipOptions',
|
|
294
|
+
field: 'title',
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
expect(result).toEqual({
|
|
298
|
+
success: false,
|
|
299
|
+
error: 'Field "title" on list "Post" is not a relationship field',
|
|
300
|
+
})
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('returns an error when the field does not exist', async () => {
|
|
304
|
+
const context = await getContext(config, mockPrisma, null)
|
|
305
|
+
const result = await context.serverAction({
|
|
306
|
+
listKey: 'Post',
|
|
307
|
+
action: 'relationshipOptions',
|
|
308
|
+
field: 'nonexistent',
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
expect(result).toEqual({
|
|
312
|
+
success: false,
|
|
313
|
+
error: 'Field "nonexistent" on list "Post" is not a relationship field',
|
|
314
|
+
})
|
|
315
|
+
})
|
|
316
|
+
})
|
|
203
317
|
})
|
|
204
318
|
|
|
205
319
|
describe('db operations', () => {
|
|
@@ -705,6 +819,112 @@ describe('getContext', () => {
|
|
|
705
819
|
// identical `accessControlledInclude === undefined` branch verified above,
|
|
706
820
|
// so the resolveOutput probe covers all three non-denial cases.
|
|
707
821
|
})
|
|
822
|
+
|
|
823
|
+
// Regression for #628: a virtual field named in `include` used to be
|
|
824
|
+
// forwarded straight through to Prisma, which throws "Unknown field"
|
|
825
|
+
// because virtual fields have no database column. The runtime must
|
|
826
|
+
// strip virtual keys from the include payload while still computing
|
|
827
|
+
// the virtual value (via resolveOutput) and leaving real relationship
|
|
828
|
+
// includes — access-controlled or not — untouched.
|
|
829
|
+
describe('virtual fields named in include no longer reach Prisma (#628)', () => {
|
|
830
|
+
function configWithVirtualDisplayName(): OpenSaasConfig {
|
|
831
|
+
return {
|
|
832
|
+
...relConfig,
|
|
833
|
+
lists: {
|
|
834
|
+
...relConfig.lists,
|
|
835
|
+
Author: {
|
|
836
|
+
...relConfig.lists.Author,
|
|
837
|
+
fields: {
|
|
838
|
+
...relConfig.lists.Author.fields,
|
|
839
|
+
displayName: virtual({
|
|
840
|
+
type: 'string',
|
|
841
|
+
hooks: {
|
|
842
|
+
resolveOutput: ({ item }) => `Author: ${item.name}`,
|
|
843
|
+
},
|
|
844
|
+
}),
|
|
845
|
+
},
|
|
846
|
+
},
|
|
847
|
+
},
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
it('findMany: strips the virtual key from the Prisma include but keeps the real relationship include, and computes the virtual value', async () => {
|
|
852
|
+
const vConfig = configWithVirtualDisplayName()
|
|
853
|
+
relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo', posts: [] }])
|
|
854
|
+
|
|
855
|
+
const context = await getContext(vConfig, relPrisma, null)
|
|
856
|
+
const result = await context.db.author.findMany({
|
|
857
|
+
include: { displayName: true, posts: true },
|
|
858
|
+
})
|
|
859
|
+
|
|
860
|
+
const call = relPrisma.author.findMany.mock.calls[0][0]
|
|
861
|
+
expect(call.include).not.toHaveProperty('displayName')
|
|
862
|
+
expect(call.include.posts).toBeDefined()
|
|
863
|
+
expect(result[0].displayName).toBe('Author: Jo')
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
it('findUnique: strips the virtual key from the Prisma include but keeps the real relationship include, and computes the virtual value', async () => {
|
|
867
|
+
const vConfig = configWithVirtualDisplayName()
|
|
868
|
+
relPrisma.author.findFirst.mockResolvedValue({ id: 'a1', name: 'Jo', posts: [] })
|
|
869
|
+
|
|
870
|
+
const context = await getContext(vConfig, relPrisma, null)
|
|
871
|
+
const result = await context.db.author.findUnique({
|
|
872
|
+
where: { id: 'a1' },
|
|
873
|
+
include: { displayName: true, posts: true },
|
|
874
|
+
})
|
|
875
|
+
|
|
876
|
+
const call = relPrisma.author.findFirst.mock.calls[0][0]
|
|
877
|
+
expect(call.include).not.toHaveProperty('displayName')
|
|
878
|
+
expect(call.include.posts).toBeDefined()
|
|
879
|
+
expect(result.displayName).toBe('Author: Jo')
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
it('the virtual value is populated even when omitted from include', async () => {
|
|
883
|
+
const vConfig = configWithVirtualDisplayName()
|
|
884
|
+
relPrisma.author.findFirst.mockResolvedValue({ id: 'a1', name: 'Jo' })
|
|
885
|
+
|
|
886
|
+
const context = await getContext(vConfig, relPrisma, null)
|
|
887
|
+
const result = await context.db.author.findUnique({ where: { id: 'a1' } })
|
|
888
|
+
|
|
889
|
+
expect(result.displayName).toBe('Author: Jo')
|
|
890
|
+
})
|
|
891
|
+
|
|
892
|
+
it('sudo: the virtual key is stripped from the Prisma include even though it bypasses access control', async () => {
|
|
893
|
+
const vConfig = configWithVirtualDisplayName()
|
|
894
|
+
relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo' }])
|
|
895
|
+
|
|
896
|
+
const context = await getContext(vConfig, relPrisma, null).sudo()
|
|
897
|
+
const result = await context.db.author.findMany({
|
|
898
|
+
include: { displayName: true, posts: true },
|
|
899
|
+
})
|
|
900
|
+
|
|
901
|
+
const call = relPrisma.author.findMany.mock.calls[0][0]
|
|
902
|
+
expect(call.include).not.toHaveProperty('displayName')
|
|
903
|
+
// The real relationship is unaffected — sudo still passes it through as-is.
|
|
904
|
+
expect(call.include.posts).toBe(true)
|
|
905
|
+
expect(result[0].displayName).toBe('Author: Jo')
|
|
906
|
+
})
|
|
907
|
+
|
|
908
|
+
it('read access control on the virtual field is still enforced (denied field is omitted)', async () => {
|
|
909
|
+
const vConfig = configWithVirtualDisplayName()
|
|
910
|
+
vConfig.lists.Author.fields.displayName = virtual({
|
|
911
|
+
type: 'string',
|
|
912
|
+
access: { read: () => false },
|
|
913
|
+
hooks: {
|
|
914
|
+
resolveOutput: ({ item }) => `Author: ${item.name}`,
|
|
915
|
+
},
|
|
916
|
+
})
|
|
917
|
+
relPrisma.author.findFirst.mockResolvedValue({ id: 'a1', name: 'Jo' })
|
|
918
|
+
|
|
919
|
+
const context = await getContext(vConfig, relPrisma, null)
|
|
920
|
+
const result = await context.db.author.findUnique({
|
|
921
|
+
where: { id: 'a1' },
|
|
922
|
+
include: { displayName: true },
|
|
923
|
+
})
|
|
924
|
+
|
|
925
|
+
expect(result).not.toHaveProperty('displayName')
|
|
926
|
+
})
|
|
927
|
+
})
|
|
708
928
|
})
|
|
709
929
|
|
|
710
930
|
it('should delegate create to prisma with access control and hooks', async () => {
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { getLabelFieldName, getItemLabel } from '../src/config/label.js'
|
|
3
|
+
import type { ListConfig, TypeInfo } from '../src/config/types.js'
|
|
4
|
+
|
|
5
|
+
function makeListConfig(
|
|
6
|
+
fields: ListConfig<TypeInfo>['fields'],
|
|
7
|
+
labelField?: string,
|
|
8
|
+
): ListConfig<TypeInfo> {
|
|
9
|
+
return {
|
|
10
|
+
fields,
|
|
11
|
+
ui: labelField !== undefined ? { labelField } : undefined,
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('label seam', () => {
|
|
16
|
+
describe('getLabelFieldName', () => {
|
|
17
|
+
it('uses the configured ui.labelField when it references a scalar field', () => {
|
|
18
|
+
const listConfig = makeListConfig(
|
|
19
|
+
{ name: { type: 'text' }, email: { type: 'text' } },
|
|
20
|
+
'email',
|
|
21
|
+
)
|
|
22
|
+
expect(getLabelFieldName(listConfig)).toBe('email')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('falls back to "name" when no ui.labelField is configured', () => {
|
|
26
|
+
const listConfig = makeListConfig({ name: { type: 'text' }, title: { type: 'text' } })
|
|
27
|
+
expect(getLabelFieldName(listConfig)).toBe('name')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('falls back to "title" when "name" is not declared', () => {
|
|
31
|
+
const listConfig = makeListConfig({ title: { type: 'text' }, body: { type: 'text' } })
|
|
32
|
+
expect(getLabelFieldName(listConfig)).toBe('title')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('falls back to "id" when neither "name" nor "title" is declared', () => {
|
|
36
|
+
const listConfig = makeListConfig({ body: { type: 'text' } })
|
|
37
|
+
expect(getLabelFieldName(listConfig)).toBe('id')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('throws when ui.labelField references a field not declared on the list', () => {
|
|
41
|
+
const listConfig = makeListConfig({ name: { type: 'text' } }, 'nickname')
|
|
42
|
+
expect(() => getLabelFieldName(listConfig)).toThrow(
|
|
43
|
+
/ui.labelField "nickname" does not reference a field declared on this list/,
|
|
44
|
+
)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('throws when ui.labelField references a relationship field', () => {
|
|
48
|
+
const listConfig = makeListConfig(
|
|
49
|
+
{ name: { type: 'text' }, author: { type: 'relationship' } },
|
|
50
|
+
'author',
|
|
51
|
+
)
|
|
52
|
+
expect(() => getLabelFieldName(listConfig)).toThrow(
|
|
53
|
+
/ui.labelField "author" must reference a scalar field, not a relationship/,
|
|
54
|
+
)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('getItemLabel', () => {
|
|
59
|
+
it('reads the resolved label field off the row', () => {
|
|
60
|
+
const listConfig = makeListConfig({ name: { type: 'text' } })
|
|
61
|
+
expect(getItemLabel(listConfig, { id: '1', name: 'Ada Lovelace' })).toBe('Ada Lovelace')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('falls back to id when the resolved field is missing from the row', () => {
|
|
65
|
+
const listConfig = makeListConfig({ name: { type: 'text' } })
|
|
66
|
+
// e.g. stripped by field-level access
|
|
67
|
+
expect(getItemLabel(listConfig, { id: 'row-1' })).toBe('row-1')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('falls back to id when the resolved field is null on the row', () => {
|
|
71
|
+
const listConfig = makeListConfig({ name: { type: 'text' } })
|
|
72
|
+
expect(getItemLabel(listConfig, { id: 'row-2', name: null })).toBe('row-2')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('respects a configured ui.labelField', () => {
|
|
76
|
+
const listConfig = makeListConfig(
|
|
77
|
+
{ name: { type: 'text' }, email: { type: 'text' } },
|
|
78
|
+
'email',
|
|
79
|
+
)
|
|
80
|
+
expect(getItemLabel(listConfig, { id: '1', name: 'Ada', email: 'ada@example.com' })).toBe(
|
|
81
|
+
'ada@example.com',
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('projection⇄render agreement', () => {
|
|
87
|
+
const cases: Array<{
|
|
88
|
+
description: string
|
|
89
|
+
labelField?: string
|
|
90
|
+
fields: ListConfig<TypeInfo>['fields']
|
|
91
|
+
row: Record<string, unknown>
|
|
92
|
+
}> = [
|
|
93
|
+
{
|
|
94
|
+
description: 'configured labelField, field present on row',
|
|
95
|
+
labelField: 'email',
|
|
96
|
+
fields: { name: { type: 'text' }, email: { type: 'text' } },
|
|
97
|
+
row: { id: '1', name: 'Ada', email: 'ada@example.com' },
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
description: 'no labelField, name present',
|
|
101
|
+
fields: { name: { type: 'text' }, title: { type: 'text' } },
|
|
102
|
+
row: { id: '2', name: 'Alan', title: 'Dr' },
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
description: 'no labelField, only title present',
|
|
106
|
+
fields: { title: { type: 'text' } },
|
|
107
|
+
row: { id: '3', title: 'Untitled Post' },
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
description: 'no labelField, no name/title, falls back to id',
|
|
111
|
+
fields: { body: { type: 'text' } },
|
|
112
|
+
row: { id: '4', body: 'hello' },
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
description: 'configured labelField, field stripped from row by access control',
|
|
116
|
+
labelField: 'email',
|
|
117
|
+
fields: { name: { type: 'text' }, email: { type: 'text' } },
|
|
118
|
+
row: { id: '5', name: 'Grace' },
|
|
119
|
+
},
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
it.each(cases)(
|
|
123
|
+
'the field getLabelFieldName resolves is the field getItemLabel reads: $description',
|
|
124
|
+
({ labelField, fields, row }) => {
|
|
125
|
+
const listConfig = makeListConfig(fields, labelField)
|
|
126
|
+
const resolvedFieldName = getLabelFieldName(listConfig)
|
|
127
|
+
const expectedLabel = Object.prototype.hasOwnProperty.call(row, resolvedFieldName)
|
|
128
|
+
? (row[resolvedFieldName] ?? row.id)
|
|
129
|
+
: row.id
|
|
130
|
+
|
|
131
|
+
expect(getItemLabel(listConfig, row)).toBe(String(expectedLabel))
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
})
|
|
135
|
+
})
|