prisma-generator-express 1.59.0 → 1.61.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/README.md +150 -14
- package/dist/client/encodeQueryParams.js +19 -23
- package/dist/client/encodeQueryParams.js.map +1 -1
- package/dist/copy/operationDefinitions.d.ts +37 -0
- package/dist/copy/operationDefinitions.js +412 -0
- package/dist/copy/operationDefinitions.js.map +1 -0
- package/dist/generators/generateFastifyHandler.js +10 -41
- package/dist/generators/generateFastifyHandler.js.map +1 -1
- package/dist/generators/generateHonoHandler.js +10 -41
- package/dist/generators/generateHonoHandler.js.map +1 -1
- package/dist/generators/generateModelMetadata.d.ts +8 -0
- package/dist/generators/generateModelMetadata.js +77 -0
- package/dist/generators/generateModelMetadata.js.map +1 -0
- package/dist/generators/generateOperationCore.js +20 -30
- package/dist/generators/generateOperationCore.js.map +1 -1
- package/dist/generators/generateQueryBuilderHelper.js +1 -1
- package/dist/generators/generateQueryBuilderHelper.js.map +1 -1
- package/dist/generators/generateRouteConfigType.js +11 -57
- package/dist/generators/generateRouteConfigType.js.map +1 -1
- package/dist/generators/generateRouter.js +64 -177
- package/dist/generators/generateRouter.js.map +1 -1
- package/dist/generators/generateRouterFastify.js +59 -169
- package/dist/generators/generateRouterFastify.js.map +1 -1
- package/dist/generators/generateRouterHono.js +52 -152
- package/dist/generators/generateRouterHono.js.map +1 -1
- package/dist/generators/generateUnifiedHandler.js +7 -30
- package/dist/generators/generateUnifiedHandler.js.map +1 -1
- package/dist/generators/generateUnifiedScalarUI.js +9 -74
- package/dist/generators/generateUnifiedScalarUI.js.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/utils/copyFiles.js +7 -0
- package/dist/utils/copyFiles.js.map +1 -1
- package/dist/utils/writeFileSafely.js +5 -12
- package/dist/utils/writeFileSafely.js.map +1 -1
- package/package.json +12 -2
- package/src/client/encodeQueryParams.ts +23 -36
- package/src/copy/autoIncludePlanner.ts +14 -23
- package/src/copy/autoIncludePlannerGuarded.ts +477 -0
- package/src/copy/autoIncludeRuntime.ts +269 -261
- package/src/copy/autoIncludeRuntimeGuarded.ts +379 -0
- package/src/copy/buildModelOpenApi.ts +248 -628
- package/src/copy/concurrency.ts +20 -0
- package/src/copy/docsRenderer.ts +63 -333
- package/src/copy/errorMapper.ts +126 -0
- package/src/copy/guardHelpers.ts +56 -0
- package/src/copy/materializedCount.ts +68 -0
- package/src/copy/materializedRouter.ts +33 -29
- package/src/copy/operationDefinitions.ts +359 -35
- package/src/copy/operationRuntime.ts +11 -605
- package/src/copy/pagination.ts +151 -0
- package/src/copy/scalarTypes.ts +2 -0
- package/src/copy/sse.ts +296 -0
- package/src/generators/generateFastifyHandler.ts +13 -47
- package/src/generators/generateHonoHandler.ts +13 -47
- package/src/generators/generateModelMetadata.ts +92 -0
- package/src/generators/generateOperationCore.ts +19 -32
- package/src/generators/generateQueryBuilderHelper.ts +1 -1
- package/src/generators/generateRouteConfigType.ts +9 -60
- package/src/generators/generateRouter.ts +88 -180
- package/src/generators/generateRouterFastify.ts +65 -172
- package/src/generators/generateRouterHono.ts +58 -155
- package/src/generators/generateUnifiedHandler.ts +8 -33
- package/src/generators/generateUnifiedScalarUI.ts +9 -91
- package/src/index.ts +13 -1
- package/src/utils/copyFiles.ts +7 -0
- package/src/utils/writeFileSafely.ts +5 -11
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { HttpError } from './errorMapper'
|
|
2
|
+
import type { PaginationCountSource } from './routeConfig'
|
|
3
|
+
|
|
4
|
+
export const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
5
|
+
|
|
6
|
+
type PrismaRawClient = {
|
|
7
|
+
$queryRawUnsafe?: <T = unknown>(sql: string, ...values: unknown[]) => Promise<T>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function quoteIdent(name: string): string {
|
|
11
|
+
if (!IDENT_RE.test(name)) {
|
|
12
|
+
throw new HttpError(400, 'invalid identifier: ' + name)
|
|
13
|
+
}
|
|
14
|
+
return '"' + name.replace(/"/g, '""') + '"'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function buildMaterializedCountFqn(
|
|
18
|
+
source: Extract<PaginationCountSource, { type: 'materializedView' }>,
|
|
19
|
+
): string {
|
|
20
|
+
return source.schema
|
|
21
|
+
? quoteIdent(source.schema) + '.' + quoteIdent(source.relation)
|
|
22
|
+
: quoteIdent(source.relation)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function buildMaterializedCountWhere(
|
|
26
|
+
where: Record<string, unknown> | undefined,
|
|
27
|
+
): { sql: string; values: unknown[] } {
|
|
28
|
+
if (!where || Object.keys(where).length === 0) {
|
|
29
|
+
return { sql: '', values: [] }
|
|
30
|
+
}
|
|
31
|
+
const values: unknown[] = []
|
|
32
|
+
const clauses: string[] = []
|
|
33
|
+
for (const [key, value] of Object.entries(where)) {
|
|
34
|
+
if (value === null) {
|
|
35
|
+
clauses.push(quoteIdent(key) + ' IS NULL')
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
values.push(value)
|
|
39
|
+
clauses.push(quoteIdent(key) + ' = $' + values.length)
|
|
40
|
+
}
|
|
41
|
+
return { sql: ' WHERE ' + clauses.join(' AND '), values }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function countFromMaterializedView(
|
|
45
|
+
client: unknown,
|
|
46
|
+
source: Extract<PaginationCountSource, { type: 'materializedView' }>,
|
|
47
|
+
): Promise<number> {
|
|
48
|
+
const raw = client as PrismaRawClient
|
|
49
|
+
if (typeof raw.$queryRawUnsafe !== 'function') {
|
|
50
|
+
throw new HttpError(500, 'Materialized count source requires $queryRawUnsafe on the Prisma client')
|
|
51
|
+
}
|
|
52
|
+
const column = source.column ?? 'total'
|
|
53
|
+
const where = buildMaterializedCountWhere(source.where)
|
|
54
|
+
const sql =
|
|
55
|
+
'SELECT ' +
|
|
56
|
+
quoteIdent(column) +
|
|
57
|
+
' AS "total" FROM ' +
|
|
58
|
+
buildMaterializedCountFqn(source) +
|
|
59
|
+
where.sql +
|
|
60
|
+
' LIMIT 1'
|
|
61
|
+
const rows = await raw.$queryRawUnsafe<Array<{ total: unknown }>>(sql, ...where.values)
|
|
62
|
+
const value = rows[0]?.total
|
|
63
|
+
const total = Number(value)
|
|
64
|
+
if (!Number.isFinite(total)) {
|
|
65
|
+
throw new HttpError(500, 'Materialized count source did not return a numeric total')
|
|
66
|
+
}
|
|
67
|
+
return Math.trunc(total)
|
|
68
|
+
}
|
|
@@ -6,7 +6,8 @@ import type {
|
|
|
6
6
|
Response,
|
|
7
7
|
Router,
|
|
8
8
|
} from 'express'
|
|
9
|
-
import { HttpError, mapError
|
|
9
|
+
import { HttpError, mapError } from './errorMapper'
|
|
10
|
+
import { transformResult } from './operationRuntime'
|
|
10
11
|
|
|
11
12
|
type SortDirection = 'asc' | 'desc'
|
|
12
13
|
type NullsOrder = 'first' | 'last'
|
|
@@ -48,7 +49,8 @@ type MaterializedRouterOptions = {
|
|
|
48
49
|
|
|
49
50
|
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
50
51
|
|
|
51
|
-
export const forbidden = (message: string): HttpError =>
|
|
52
|
+
export const forbidden = (message: string): HttpError =>
|
|
53
|
+
new HttpError(403, message)
|
|
52
54
|
|
|
53
55
|
const quoteIdent = (name: string): string => {
|
|
54
56
|
if (!IDENT_RE.test(name))
|
|
@@ -105,6 +107,18 @@ const buildOrderBy = (orderBy?: OrderByDef): string => {
|
|
|
105
107
|
)
|
|
106
108
|
}
|
|
107
109
|
|
|
110
|
+
const validateSort = (value: unknown): SortDirection | undefined => {
|
|
111
|
+
if (value === undefined) return undefined
|
|
112
|
+
if (value === 'asc' || value === 'desc') return value
|
|
113
|
+
throw new HttpError(400, 'invalid orderBy.sort: expected "asc" or "desc"')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const validateNullsOrder = (value: unknown): NullsOrder | undefined => {
|
|
117
|
+
if (value === undefined) return undefined
|
|
118
|
+
if (value === 'first' || value === 'last') return value
|
|
119
|
+
throw new HttpError(400, 'invalid orderBy.nulls: expected "first" or "last"')
|
|
120
|
+
}
|
|
121
|
+
|
|
108
122
|
const parseOrderByParam = (raw: unknown): OrderByDef | undefined => {
|
|
109
123
|
if (raw === undefined || raw === null || raw === '') return undefined
|
|
110
124
|
if (typeof raw !== 'string') return undefined
|
|
@@ -124,12 +138,10 @@ const parseOrderByParam = (raw: unknown): OrderByDef | undefined => {
|
|
|
124
138
|
if (dirRaw === 'asc' || dirRaw === 'desc')
|
|
125
139
|
return { field, direction: dirRaw }
|
|
126
140
|
if (dirRaw && typeof dirRaw === 'object') {
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
nulls === 'first' || nulls === 'last' ? nulls : undefined
|
|
132
|
-
return { field, direction, nulls: nullsOrder }
|
|
141
|
+
const dirObj = dirRaw as { sort?: unknown; nulls?: unknown }
|
|
142
|
+
const direction = validateSort(dirObj.sort)
|
|
143
|
+
const nulls = validateNullsOrder(dirObj.nulls)
|
|
144
|
+
return { field, direction, nulls }
|
|
133
145
|
}
|
|
134
146
|
return { field }
|
|
135
147
|
}
|
|
@@ -153,8 +165,12 @@ const validateViewIdentifier = (
|
|
|
153
165
|
): void => {
|
|
154
166
|
if (!IDENT_RE.test(value)) {
|
|
155
167
|
throw new Error(
|
|
156
|
-
'materializedViewsRouter: invalid ' +
|
|
157
|
-
|
|
168
|
+
'materializedViewsRouter: invalid ' +
|
|
169
|
+
fieldName +
|
|
170
|
+
' identifier for view "' +
|
|
171
|
+
viewName +
|
|
172
|
+
'": ' +
|
|
173
|
+
value,
|
|
158
174
|
)
|
|
159
175
|
}
|
|
160
176
|
}
|
|
@@ -172,9 +188,10 @@ const validateViewConfig = (viewName: string, def: ViewDef): void => {
|
|
|
172
188
|
}
|
|
173
189
|
if (!def.allowedOrderBy && !def.orderBy) {
|
|
174
190
|
console.warn(
|
|
175
|
-
'[materializedViewsRouter] view "' +
|
|
176
|
-
|
|
177
|
-
|
|
191
|
+
'[materializedViewsRouter] view "' +
|
|
192
|
+
viewName +
|
|
193
|
+
'" has neither `orderBy` nor `allowedOrderBy` set. ' +
|
|
194
|
+
'Clients can sort by any valid identifier, which may cause full table scans.',
|
|
178
195
|
)
|
|
179
196
|
}
|
|
180
197
|
}
|
|
@@ -198,7 +215,7 @@ export const materializedViewsRouter = (
|
|
|
198
215
|
...before,
|
|
199
216
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
200
217
|
try {
|
|
201
|
-
const viewName = req.params.viewName
|
|
218
|
+
const viewName = String(req.params.viewName)
|
|
202
219
|
const def = opts.views[viewName]
|
|
203
220
|
if (!def) throw new HttpError(404, 'unknown view')
|
|
204
221
|
if (def.authorize) await def.authorize(req, viewName, def)
|
|
@@ -247,26 +264,13 @@ export const materializedViewsRouter = (
|
|
|
247
264
|
|
|
248
265
|
router.use(
|
|
249
266
|
(err: unknown, _req: Request, res: Response, next: NextFunction) => {
|
|
250
|
-
const httpError =
|
|
251
|
-
err instanceof HttpError
|
|
252
|
-
? err
|
|
253
|
-
: err &&
|
|
254
|
-
typeof err === 'object' &&
|
|
255
|
-
typeof (err as { status?: number }).status === 'number'
|
|
256
|
-
? new HttpError(
|
|
257
|
-
(err as { status: number }).status,
|
|
258
|
-
(err as { message?: string }).message ||
|
|
259
|
-
'Internal server error',
|
|
260
|
-
)
|
|
261
|
-
: mapError(err)
|
|
262
|
-
|
|
267
|
+
const httpError = mapError(err)
|
|
263
268
|
if (!res.headersSent) {
|
|
264
269
|
return res.status(httpError.status).json({ message: httpError.message })
|
|
265
270
|
}
|
|
266
|
-
|
|
267
271
|
next(err)
|
|
268
272
|
},
|
|
269
273
|
)
|
|
270
274
|
|
|
271
275
|
return router
|
|
272
|
-
}
|
|
276
|
+
}
|
|
@@ -1,122 +1,446 @@
|
|
|
1
1
|
export type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'
|
|
2
2
|
|
|
3
|
-
export
|
|
3
|
+
export type OpKind = 'read' | 'write' | 'batch'
|
|
4
|
+
|
|
5
|
+
export interface OpMetadata {
|
|
4
6
|
name: string
|
|
5
7
|
method: HttpMethod
|
|
6
8
|
pathSuffix: string
|
|
7
9
|
configKey: string
|
|
8
|
-
|
|
10
|
+
kind: OpKind
|
|
11
|
+
coreName: string
|
|
12
|
+
successStatus: number
|
|
13
|
+
requiredBodyFields: readonly string[]
|
|
14
|
+
supportsPostRead: boolean
|
|
15
|
+
supportsProjection: boolean
|
|
16
|
+
excludeFromEnableAll: boolean
|
|
17
|
+
writeStrategyAffected: boolean
|
|
18
|
+
errors: readonly number[]
|
|
19
|
+
responseDesc: string
|
|
20
|
+
transport: string
|
|
21
|
+
argsSchema: readonly string[]
|
|
22
|
+
requiredArgs: readonly string[]
|
|
23
|
+
notes: string
|
|
9
24
|
}
|
|
10
25
|
|
|
11
|
-
export const
|
|
12
|
-
{
|
|
26
|
+
export const OPERATION_METADATA: readonly OpMetadata[] = [
|
|
27
|
+
{
|
|
28
|
+
name: 'findMany',
|
|
29
|
+
method: 'get',
|
|
30
|
+
pathSuffix: '',
|
|
31
|
+
configKey: 'findMany',
|
|
32
|
+
kind: 'read',
|
|
33
|
+
coreName: 'findMany',
|
|
34
|
+
successStatus: 200,
|
|
35
|
+
requiredBodyFields: [],
|
|
36
|
+
supportsPostRead: true,
|
|
37
|
+
supportsProjection: true,
|
|
38
|
+
excludeFromEnableAll: false,
|
|
39
|
+
writeStrategyAffected: false,
|
|
40
|
+
errors: [400, 403, 500, 501, 503],
|
|
41
|
+
responseDesc: 'Array of records',
|
|
42
|
+
transport: 'GET query params',
|
|
43
|
+
argsSchema: ['where', 'select', 'include', 'omit', 'orderBy', 'cursor', 'take', 'skip', 'distinct'],
|
|
44
|
+
requiredArgs: [],
|
|
45
|
+
notes: 'Pagination limits may apply when configured.',
|
|
46
|
+
},
|
|
13
47
|
{
|
|
14
48
|
name: 'findUnique',
|
|
15
49
|
method: 'get',
|
|
16
50
|
pathSuffix: '/unique',
|
|
17
51
|
configKey: 'findUnique',
|
|
52
|
+
kind: 'read',
|
|
53
|
+
coreName: 'findUnique',
|
|
54
|
+
successStatus: 200,
|
|
55
|
+
requiredBodyFields: [],
|
|
56
|
+
supportsPostRead: true,
|
|
57
|
+
supportsProjection: true,
|
|
58
|
+
excludeFromEnableAll: false,
|
|
59
|
+
writeStrategyAffected: false,
|
|
60
|
+
errors: [400, 403, 500, 501, 503],
|
|
61
|
+
responseDesc: 'Single record or null',
|
|
62
|
+
transport: 'GET query params',
|
|
63
|
+
argsSchema: ['where', 'select', 'include', 'omit'],
|
|
64
|
+
requiredArgs: ['where'],
|
|
65
|
+
notes: 'Returns null (not 404) when no record matches.',
|
|
18
66
|
},
|
|
19
67
|
{
|
|
20
68
|
name: 'findUniqueOrThrow',
|
|
21
69
|
method: 'get',
|
|
22
70
|
pathSuffix: '/unique/strict',
|
|
23
71
|
configKey: 'findUniqueOrThrow',
|
|
72
|
+
kind: 'read',
|
|
73
|
+
coreName: 'findUniqueOrThrow',
|
|
74
|
+
successStatus: 200,
|
|
75
|
+
requiredBodyFields: [],
|
|
76
|
+
supportsPostRead: true,
|
|
77
|
+
supportsProjection: true,
|
|
78
|
+
excludeFromEnableAll: false,
|
|
79
|
+
writeStrategyAffected: false,
|
|
80
|
+
errors: [400, 403, 404, 500, 501, 503],
|
|
81
|
+
responseDesc: 'Single record',
|
|
82
|
+
transport: 'GET query params',
|
|
83
|
+
argsSchema: ['where', 'select', 'include', 'omit'],
|
|
84
|
+
requiredArgs: ['where'],
|
|
85
|
+
notes: 'Returns 404 when no record matches.',
|
|
24
86
|
},
|
|
25
87
|
{
|
|
26
88
|
name: 'findFirst',
|
|
27
89
|
method: 'get',
|
|
28
90
|
pathSuffix: '/first',
|
|
29
91
|
configKey: 'findFirst',
|
|
92
|
+
kind: 'read',
|
|
93
|
+
coreName: 'findFirst',
|
|
94
|
+
successStatus: 200,
|
|
95
|
+
requiredBodyFields: [],
|
|
96
|
+
supportsPostRead: true,
|
|
97
|
+
supportsProjection: true,
|
|
98
|
+
excludeFromEnableAll: false,
|
|
99
|
+
writeStrategyAffected: false,
|
|
100
|
+
errors: [400, 403, 500, 501, 503],
|
|
101
|
+
responseDesc: 'Single record or null',
|
|
102
|
+
transport: 'GET query params',
|
|
103
|
+
argsSchema: ['where', 'select', 'include', 'omit', 'orderBy', 'cursor', 'take', 'skip', 'distinct'],
|
|
104
|
+
requiredArgs: [],
|
|
105
|
+
notes: 'Returns null (not 404) when no record matches.',
|
|
30
106
|
},
|
|
31
107
|
{
|
|
32
108
|
name: 'findFirstOrThrow',
|
|
33
109
|
method: 'get',
|
|
34
110
|
pathSuffix: '/first/strict',
|
|
35
111
|
configKey: 'findFirstOrThrow',
|
|
112
|
+
kind: 'read',
|
|
113
|
+
coreName: 'findFirstOrThrow',
|
|
114
|
+
successStatus: 200,
|
|
115
|
+
requiredBodyFields: [],
|
|
116
|
+
supportsPostRead: true,
|
|
117
|
+
supportsProjection: true,
|
|
118
|
+
excludeFromEnableAll: false,
|
|
119
|
+
writeStrategyAffected: false,
|
|
120
|
+
errors: [400, 403, 404, 500, 501, 503],
|
|
121
|
+
responseDesc: 'Single record',
|
|
122
|
+
transport: 'GET query params',
|
|
123
|
+
argsSchema: ['where', 'select', 'include', 'omit', 'orderBy', 'cursor', 'take', 'skip', 'distinct'],
|
|
124
|
+
requiredArgs: [],
|
|
125
|
+
notes: 'Returns 404 when no record matches.',
|
|
36
126
|
},
|
|
37
127
|
{
|
|
38
128
|
name: 'findManyPaginated',
|
|
39
129
|
method: 'get',
|
|
40
130
|
pathSuffix: '/paginated',
|
|
41
131
|
configKey: 'findManyPaginated',
|
|
132
|
+
kind: 'read',
|
|
133
|
+
coreName: 'findManyPaginated',
|
|
134
|
+
successStatus: 200,
|
|
135
|
+
requiredBodyFields: [],
|
|
136
|
+
supportsPostRead: true,
|
|
137
|
+
supportsProjection: true,
|
|
138
|
+
excludeFromEnableAll: false,
|
|
139
|
+
writeStrategyAffected: false,
|
|
140
|
+
errors: [400, 403, 409, 500, 501, 503],
|
|
141
|
+
responseDesc: '{ data: Record[], total: number, hasMore: boolean }',
|
|
142
|
+
transport: 'GET query params',
|
|
143
|
+
argsSchema: ['where', 'select', 'include', 'omit', 'orderBy', 'cursor', 'take', 'skip', 'distinct'],
|
|
144
|
+
requiredArgs: [],
|
|
145
|
+
notes: 'Wraps findMany with total count. hasMore is reliable for forward offset pagination (skip + take) only. Distinct count over 100k falls back to approximate total. 409 possible on transaction conflict.',
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: 'count',
|
|
149
|
+
method: 'get',
|
|
150
|
+
pathSuffix: '/count',
|
|
151
|
+
configKey: 'count',
|
|
152
|
+
kind: 'read',
|
|
153
|
+
coreName: 'count',
|
|
154
|
+
successStatus: 200,
|
|
155
|
+
requiredBodyFields: [],
|
|
156
|
+
supportsPostRead: true,
|
|
157
|
+
supportsProjection: false,
|
|
158
|
+
excludeFromEnableAll: false,
|
|
159
|
+
writeStrategyAffected: false,
|
|
160
|
+
errors: [400, 403, 500, 501, 503],
|
|
161
|
+
responseDesc: 'Integer, or per-field count object when select is provided',
|
|
162
|
+
transport: 'GET query params',
|
|
163
|
+
argsSchema: ['where', 'orderBy', 'cursor', 'take', 'skip', 'select'],
|
|
164
|
+
requiredArgs: [],
|
|
165
|
+
notes: 'select here means count-specific field selection, not record field selection.',
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: 'aggregate',
|
|
169
|
+
method: 'get',
|
|
170
|
+
pathSuffix: '/aggregate',
|
|
171
|
+
configKey: 'aggregate',
|
|
172
|
+
kind: 'read',
|
|
173
|
+
coreName: 'aggregate',
|
|
174
|
+
successStatus: 200,
|
|
175
|
+
requiredBodyFields: [],
|
|
176
|
+
supportsPostRead: true,
|
|
177
|
+
supportsProjection: false,
|
|
178
|
+
excludeFromEnableAll: false,
|
|
179
|
+
writeStrategyAffected: false,
|
|
180
|
+
errors: [400, 403, 500, 501, 503],
|
|
181
|
+
responseDesc: 'Object with requested aggregate fields (_count, _avg, _sum, _min, _max)',
|
|
182
|
+
transport: 'GET query params',
|
|
183
|
+
argsSchema: ['where', 'orderBy', 'cursor', 'take', 'skip', '_count', '_avg', '_sum', '_min', '_max'],
|
|
184
|
+
requiredArgs: [],
|
|
185
|
+
notes: '_avg, _sum only apply to numeric fields.',
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: 'groupBy',
|
|
189
|
+
method: 'get',
|
|
190
|
+
pathSuffix: '/groupby',
|
|
191
|
+
configKey: 'groupBy',
|
|
192
|
+
kind: 'read',
|
|
193
|
+
coreName: 'groupBy',
|
|
194
|
+
successStatus: 200,
|
|
195
|
+
requiredBodyFields: [],
|
|
196
|
+
supportsPostRead: true,
|
|
197
|
+
supportsProjection: false,
|
|
198
|
+
excludeFromEnableAll: false,
|
|
199
|
+
writeStrategyAffected: false,
|
|
200
|
+
errors: [400, 403, 500, 501, 503],
|
|
201
|
+
responseDesc: 'Array of objects, each with grouped field values and requested aggregates',
|
|
202
|
+
transport: 'GET query params',
|
|
203
|
+
argsSchema: ['by', 'where', 'orderBy', 'having', 'take', 'skip', '_count', '_avg', '_sum', '_min', '_max'],
|
|
204
|
+
requiredArgs: ['by'],
|
|
205
|
+
notes: 'by is a JSON-encoded array of scalar field names. orderBy is required when using skip or take. Response contains only the by-fields plus requested aggregates.',
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
name: 'create',
|
|
209
|
+
method: 'post',
|
|
210
|
+
pathSuffix: '',
|
|
211
|
+
configKey: 'create',
|
|
212
|
+
kind: 'write',
|
|
213
|
+
coreName: 'create',
|
|
214
|
+
successStatus: 201,
|
|
215
|
+
requiredBodyFields: ['data'],
|
|
216
|
+
supportsPostRead: false,
|
|
217
|
+
supportsProjection: true,
|
|
218
|
+
excludeFromEnableAll: false,
|
|
219
|
+
writeStrategyAffected: false,
|
|
220
|
+
errors: [400, 403, 409, 500, 501, 503],
|
|
221
|
+
responseDesc: 'Created record (201)',
|
|
222
|
+
transport: 'POST JSON body',
|
|
223
|
+
argsSchema: ['data', 'select', 'include', 'omit'],
|
|
224
|
+
requiredArgs: ['data'],
|
|
225
|
+
notes: '409 on unique constraint violation.',
|
|
42
226
|
},
|
|
43
|
-
{ name: 'create', method: 'post', pathSuffix: '', configKey: 'create' },
|
|
44
227
|
{
|
|
45
228
|
name: 'createMany',
|
|
46
229
|
method: 'post',
|
|
47
230
|
pathSuffix: '/many',
|
|
48
231
|
configKey: 'createMany',
|
|
232
|
+
kind: 'batch',
|
|
233
|
+
coreName: 'createMany',
|
|
234
|
+
successStatus: 201,
|
|
235
|
+
requiredBodyFields: ['data'],
|
|
236
|
+
supportsPostRead: false,
|
|
237
|
+
supportsProjection: false,
|
|
238
|
+
excludeFromEnableAll: false,
|
|
239
|
+
writeStrategyAffected: true,
|
|
240
|
+
errors: [400, 403, 409, 500, 501, 503],
|
|
241
|
+
responseDesc: '{ count: number } (201)',
|
|
242
|
+
transport: 'POST JSON body',
|
|
243
|
+
argsSchema: ['data', 'skipDuplicates'],
|
|
244
|
+
requiredArgs: ['data'],
|
|
245
|
+
notes: 'data is an array of scalar-only inputs. Nested relation writes are not supported. skipDuplicates silently ignores conflicts (not supported on all providers).',
|
|
49
246
|
},
|
|
50
247
|
{
|
|
51
248
|
name: 'createManyAndReturn',
|
|
52
249
|
method: 'post',
|
|
53
250
|
pathSuffix: '/many/return',
|
|
54
251
|
configKey: 'createManyAndReturn',
|
|
252
|
+
kind: 'batch',
|
|
253
|
+
coreName: 'createManyAndReturn',
|
|
254
|
+
successStatus: 201,
|
|
255
|
+
requiredBodyFields: ['data'],
|
|
256
|
+
supportsPostRead: false,
|
|
257
|
+
supportsProjection: true,
|
|
258
|
+
excludeFromEnableAll: false,
|
|
259
|
+
writeStrategyAffected: false,
|
|
260
|
+
errors: [400, 403, 409, 500, 501, 503],
|
|
261
|
+
responseDesc: 'Array of created records (201)',
|
|
262
|
+
transport: 'POST JSON body',
|
|
263
|
+
argsSchema: ['data', 'skipDuplicates', 'select', 'include', 'omit'],
|
|
264
|
+
requiredArgs: ['data'],
|
|
265
|
+
notes: 'Like createMany but returns created records. data items are scalar-only. Requires Prisma 5.14.0+, PostgreSQL/CockroachDB/SQLite only. The order of returned records is not guaranteed.',
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
name: 'update',
|
|
269
|
+
method: 'put',
|
|
270
|
+
pathSuffix: '',
|
|
271
|
+
configKey: 'update',
|
|
272
|
+
kind: 'write',
|
|
273
|
+
coreName: 'update',
|
|
274
|
+
successStatus: 200,
|
|
275
|
+
requiredBodyFields: ['where', 'data'],
|
|
276
|
+
supportsPostRead: false,
|
|
277
|
+
supportsProjection: true,
|
|
278
|
+
excludeFromEnableAll: false,
|
|
279
|
+
writeStrategyAffected: false,
|
|
280
|
+
errors: [400, 403, 404, 409, 500, 501, 503],
|
|
281
|
+
responseDesc: 'Updated record',
|
|
282
|
+
transport: 'PUT JSON body',
|
|
283
|
+
argsSchema: ['where', 'data', 'select', 'include', 'omit'],
|
|
284
|
+
requiredArgs: ['where', 'data'],
|
|
285
|
+
notes: '404 when the record to update is not found. 409 on unique constraint violation or transaction conflict.',
|
|
55
286
|
},
|
|
56
|
-
{ name: 'update', method: 'put', pathSuffix: '', configKey: 'update' },
|
|
57
287
|
{
|
|
58
288
|
name: 'updateMany',
|
|
59
289
|
method: 'put',
|
|
60
290
|
pathSuffix: '/many',
|
|
61
291
|
configKey: 'updateMany',
|
|
292
|
+
kind: 'batch',
|
|
293
|
+
coreName: 'updateMany',
|
|
294
|
+
successStatus: 200,
|
|
295
|
+
requiredBodyFields: ['where', 'data'],
|
|
296
|
+
supportsPostRead: false,
|
|
297
|
+
supportsProjection: false,
|
|
298
|
+
excludeFromEnableAll: false,
|
|
299
|
+
writeStrategyAffected: true,
|
|
300
|
+
errors: [400, 403, 409, 500, 501, 503],
|
|
301
|
+
responseDesc: '{ count: number }',
|
|
302
|
+
transport: 'PUT JSON body',
|
|
303
|
+
argsSchema: ['where', 'data'],
|
|
304
|
+
requiredArgs: ['where', 'data'],
|
|
305
|
+
notes: 'Updates all matching records with scalar-only data. Nested relation writes are not supported. Returns count, not records. 409 on unique constraint violation.',
|
|
62
306
|
},
|
|
63
307
|
{
|
|
64
308
|
name: 'updateManyAndReturn',
|
|
65
309
|
method: 'put',
|
|
66
310
|
pathSuffix: '/many/return',
|
|
67
311
|
configKey: 'updateManyAndReturn',
|
|
312
|
+
kind: 'batch',
|
|
313
|
+
coreName: 'updateManyAndReturn',
|
|
314
|
+
successStatus: 200,
|
|
315
|
+
requiredBodyFields: ['where', 'data'],
|
|
316
|
+
supportsPostRead: false,
|
|
317
|
+
supportsProjection: true,
|
|
318
|
+
excludeFromEnableAll: false,
|
|
319
|
+
writeStrategyAffected: false,
|
|
320
|
+
errors: [400, 403, 409, 500, 501, 503],
|
|
321
|
+
responseDesc: 'Array of updated records',
|
|
322
|
+
transport: 'PUT JSON body',
|
|
323
|
+
argsSchema: ['where', 'data', 'select', 'include', 'omit'],
|
|
324
|
+
requiredArgs: ['where', 'data'],
|
|
325
|
+
notes: 'Like updateMany but returns updated records. data is scalar-only. Requires Prisma 6.2.0+, PostgreSQL/CockroachDB/SQLite only. 409 on unique constraint violation.',
|
|
68
326
|
},
|
|
69
|
-
{ name: 'upsert', method: 'patch', pathSuffix: '', configKey: 'upsert' },
|
|
70
|
-
{ name: 'delete', method: 'delete', pathSuffix: '', configKey: 'delete' },
|
|
71
327
|
{
|
|
72
|
-
name: '
|
|
73
|
-
method: '
|
|
74
|
-
pathSuffix: '
|
|
75
|
-
configKey: '
|
|
328
|
+
name: 'upsert',
|
|
329
|
+
method: 'patch',
|
|
330
|
+
pathSuffix: '',
|
|
331
|
+
configKey: 'upsert',
|
|
332
|
+
kind: 'write',
|
|
333
|
+
coreName: 'upsert',
|
|
334
|
+
successStatus: 200,
|
|
335
|
+
requiredBodyFields: ['where', 'create', 'update'],
|
|
336
|
+
supportsPostRead: false,
|
|
337
|
+
supportsProjection: true,
|
|
338
|
+
excludeFromEnableAll: false,
|
|
339
|
+
writeStrategyAffected: false,
|
|
340
|
+
errors: [400, 403, 409, 500, 501, 503],
|
|
341
|
+
responseDesc: 'Created or updated record',
|
|
342
|
+
transport: 'PATCH JSON body',
|
|
343
|
+
argsSchema: ['where', 'create', 'update', 'select', 'include', 'omit'],
|
|
344
|
+
requiredArgs: ['where', 'create', 'update'],
|
|
345
|
+
notes: 'Creates if not found, updates if found.',
|
|
76
346
|
},
|
|
77
|
-
{ name: 'count', method: 'get', pathSuffix: '/count', configKey: 'count' },
|
|
78
347
|
{
|
|
79
|
-
name: '
|
|
80
|
-
method: '
|
|
81
|
-
pathSuffix: '
|
|
82
|
-
configKey: '
|
|
348
|
+
name: 'delete',
|
|
349
|
+
method: 'delete',
|
|
350
|
+
pathSuffix: '',
|
|
351
|
+
configKey: 'delete',
|
|
352
|
+
kind: 'write',
|
|
353
|
+
coreName: 'deleteUnique',
|
|
354
|
+
successStatus: 200,
|
|
355
|
+
requiredBodyFields: ['where'],
|
|
356
|
+
supportsPostRead: false,
|
|
357
|
+
supportsProjection: true,
|
|
358
|
+
excludeFromEnableAll: false,
|
|
359
|
+
writeStrategyAffected: false,
|
|
360
|
+
errors: [400, 403, 404, 500, 501, 503],
|
|
361
|
+
responseDesc: 'Deleted record',
|
|
362
|
+
transport: 'DELETE JSON body',
|
|
363
|
+
argsSchema: ['where', 'select', 'include', 'omit'],
|
|
364
|
+
requiredArgs: ['where'],
|
|
365
|
+
notes: '404 when the record to delete is not found.',
|
|
83
366
|
},
|
|
84
367
|
{
|
|
85
|
-
name: '
|
|
86
|
-
method: '
|
|
87
|
-
pathSuffix: '/
|
|
88
|
-
configKey: '
|
|
368
|
+
name: 'deleteMany',
|
|
369
|
+
method: 'delete',
|
|
370
|
+
pathSuffix: '/many',
|
|
371
|
+
configKey: 'deleteMany',
|
|
372
|
+
kind: 'batch',
|
|
373
|
+
coreName: 'deleteMany',
|
|
374
|
+
successStatus: 200,
|
|
375
|
+
requiredBodyFields: ['where'],
|
|
376
|
+
supportsPostRead: false,
|
|
377
|
+
supportsProjection: false,
|
|
378
|
+
excludeFromEnableAll: false,
|
|
379
|
+
writeStrategyAffected: false,
|
|
380
|
+
errors: [400, 403, 500, 501, 503],
|
|
381
|
+
responseDesc: '{ count: number }',
|
|
382
|
+
transport: 'DELETE JSON body',
|
|
383
|
+
argsSchema: ['where'],
|
|
384
|
+
requiredArgs: ['where'],
|
|
385
|
+
notes: 'Deletes all matching records. Returns count, not records.',
|
|
89
386
|
},
|
|
90
387
|
{
|
|
91
388
|
name: 'updateEach',
|
|
92
389
|
method: 'post',
|
|
93
390
|
pathSuffix: '/each',
|
|
94
391
|
configKey: 'updateEach',
|
|
392
|
+
kind: 'batch',
|
|
393
|
+
coreName: 'updateEach',
|
|
394
|
+
successStatus: 200,
|
|
395
|
+
requiredBodyFields: [],
|
|
396
|
+
supportsPostRead: false,
|
|
397
|
+
supportsProjection: false,
|
|
95
398
|
excludeFromEnableAll: true,
|
|
399
|
+
writeStrategyAffected: false,
|
|
400
|
+
errors: [400, 403, 409, 500, 501, 503],
|
|
401
|
+
responseDesc: 'Non-atomic: per-row { status, data } / { status, error } array. Atomic: array of records.',
|
|
402
|
+
transport: 'POST JSON body (array)',
|
|
403
|
+
argsSchema: [],
|
|
404
|
+
requiredArgs: ['array of { where, data }'],
|
|
405
|
+
notes: 'Internal batch endpoint. Bypasses guard shapes. Not enabled by enableAll. Non-atomic max 1000 items, atomic max 100 items. Header x-batch-atomic:true switches to transactional mode.',
|
|
96
406
|
},
|
|
97
407
|
]
|
|
98
408
|
|
|
99
|
-
export const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
409
|
+
export const OPERATION_BY_NAME: Record<string, OpMetadata> = Object.fromEntries(
|
|
410
|
+
OPERATION_METADATA.map((m) => [m.name, m]),
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
export const READ_OPERATIONS: readonly OpMetadata[] = OPERATION_METADATA.filter((m) => m.kind === 'read')
|
|
414
|
+
|
|
415
|
+
export const WRITE_OPERATIONS: readonly OpMetadata[] = OPERATION_METADATA.filter((m) => m.kind === 'write' || m.kind === 'batch')
|
|
416
|
+
|
|
417
|
+
export const READ_OPERATION_NAMES = new Set(READ_OPERATIONS.map((m) => m.name))
|
|
418
|
+
|
|
419
|
+
export interface OperationDef {
|
|
420
|
+
name: string
|
|
421
|
+
method: HttpMethod
|
|
422
|
+
pathSuffix: string
|
|
423
|
+
configKey: string
|
|
424
|
+
excludeFromEnableAll?: boolean
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export const OPERATION_DEFS: OperationDef[] = OPERATION_METADATA.map((m) => ({
|
|
428
|
+
name: m.name,
|
|
429
|
+
method: m.method,
|
|
430
|
+
pathSuffix: m.pathSuffix,
|
|
431
|
+
configKey: m.configKey,
|
|
432
|
+
excludeFromEnableAll: m.excludeFromEnableAll || undefined,
|
|
433
|
+
}))
|
|
110
434
|
|
|
111
435
|
export function getPostReadPathSuffix(opName: string): string {
|
|
112
436
|
if (opName === 'findMany') return '/read'
|
|
113
|
-
const
|
|
114
|
-
return
|
|
437
|
+
const meta = OPERATION_BY_NAME[opName]
|
|
438
|
+
return meta ? meta.pathSuffix : ''
|
|
115
439
|
}
|
|
116
440
|
|
|
117
441
|
export function isOperationEnabled(
|
|
118
442
|
config: Record<string, any>,
|
|
119
|
-
def: OperationDef,
|
|
443
|
+
def: OperationDef | OpMetadata,
|
|
120
444
|
): boolean {
|
|
121
445
|
if (config[def.configKey] === false) return false
|
|
122
446
|
if (def.excludeFromEnableAll) return !!config[def.configKey]
|