prisma-generator-express 1.54.0 → 1.56.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 +186 -11
- package/dist/constants.d.ts +2 -0
- package/dist/generators/generateFastifyHandler.js +3 -1
- package/dist/generators/generateFastifyHandler.js.map +1 -1
- package/dist/generators/generateHonoHandler.js +3 -1
- package/dist/generators/generateHonoHandler.js.map +1 -1
- package/dist/generators/generateOperationCore.d.ts +3 -0
- package/dist/generators/generateOperationCore.js +68 -39
- package/dist/generators/generateOperationCore.js.map +1 -1
- package/dist/generators/generateRouteConfigType.js +4 -1
- package/dist/generators/generateRouteConfigType.js.map +1 -1
- package/dist/generators/generateRouter.d.ts +4 -1
- package/dist/generators/generateRouter.js +20 -7
- package/dist/generators/generateRouter.js.map +1 -1
- package/dist/generators/generateRouterFastify.d.ts +4 -1
- package/dist/generators/generateRouterFastify.js +23 -9
- package/dist/generators/generateRouterFastify.js.map +1 -1
- package/dist/generators/generateRouterHono.d.ts +4 -1
- package/dist/generators/generateRouterHono.js +29 -16
- package/dist/generators/generateRouterHono.js.map +1 -1
- package/dist/generators/generateUnifiedScalarUI.d.ts +2 -1
- package/dist/generators/generateUnifiedScalarUI.js +13 -10
- package/dist/generators/generateUnifiedScalarUI.js.map +1 -1
- package/dist/index.js +42 -3
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +5 -1
- package/src/copy/autoIncludeRuntime.ts +60 -35
- package/src/copy/buildModelOpenApi.ts +144 -23
- package/src/copy/docsRenderer.ts +125 -98
- package/src/copy/operationRuntime.ts +94 -9
- package/src/copy/routeConfig.express.ts +8 -0
- package/src/copy/routeConfig.fastify.ts +8 -0
- package/src/copy/routeConfig.hono.ts +9 -1
- package/src/copy/routeConfig.ts +23 -5
- package/src/generators/generateFastifyHandler.ts +3 -1
- package/src/generators/generateHonoHandler.ts +3 -1
- package/src/generators/generateOperationCore.ts +84 -39
- package/src/generators/generateRouteConfigType.ts +5 -2
- package/src/generators/generateRouter.ts +24 -6
- package/src/generators/generateRouterFastify.ts +27 -8
- package/src/generators/generateRouterHono.ts +33 -15
- package/src/generators/generateUnifiedScalarUI.ts +15 -11
- package/src/index.ts +49 -7
package/src/copy/docsRenderer.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RouteConfig } from './routeConfig'
|
|
1
|
+
import type { RouteConfig, WriteStrategy } from './routeConfig'
|
|
2
2
|
import { OPERATION_DEFS, isOperationEnabled, READ_OPERATION_NAMES } from './operationDefinitions'
|
|
3
3
|
import { getEnv, normalizePrefix } from './misc'
|
|
4
4
|
|
|
@@ -256,6 +256,41 @@ const OP_DETAIL_MAP: Record<string, OpDetail> = {
|
|
|
256
256
|
},
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
function detailForOp(opName: string, writeStrategy?: WriteStrategy): OpDetail {
|
|
260
|
+
const base = OP_DETAIL_MAP[opName]
|
|
261
|
+
if (!base) return base
|
|
262
|
+
if (writeStrategy === 'forceReturn') {
|
|
263
|
+
if (opName === 'createMany') {
|
|
264
|
+
return {
|
|
265
|
+
...base,
|
|
266
|
+
optional: ['skipDuplicates', 'select', 'include', 'omit'],
|
|
267
|
+
responseDesc: 'Array of created records (201)',
|
|
268
|
+
supportsSelect: true,
|
|
269
|
+
supportsInclude: true,
|
|
270
|
+
supportsOmit: true,
|
|
271
|
+
notes: 'writeStrategy="forceReturn": silently invokes createManyAndReturn and returns the created records instead of { count }.',
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (opName === 'updateMany') {
|
|
275
|
+
return {
|
|
276
|
+
...base,
|
|
277
|
+
optional: ['select', 'include', 'omit'],
|
|
278
|
+
responseDesc: 'Array of updated records',
|
|
279
|
+
supportsSelect: true,
|
|
280
|
+
supportsInclude: true,
|
|
281
|
+
supportsOmit: true,
|
|
282
|
+
notes: 'writeStrategy="forceReturn": silently invokes updateManyAndReturn and returns the updated records instead of { count }.',
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return base
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function isOpHiddenByStrategy(opName: string, writeStrategy?: WriteStrategy): boolean {
|
|
290
|
+
if (writeStrategy !== 'throwOnNonReturning') return false
|
|
291
|
+
return opName === 'createMany' || opName === 'updateMany'
|
|
292
|
+
}
|
|
293
|
+
|
|
259
294
|
function exampleValue(ctx: DocsModelContext, fieldName: string): unknown {
|
|
260
295
|
return ctx.exampleValues[fieldName] ?? 'example'
|
|
261
296
|
}
|
|
@@ -409,46 +444,17 @@ function isEnumField(f: FieldMeta): boolean {
|
|
|
409
444
|
function scalarFilterOperators(scalarType: string): string[] {
|
|
410
445
|
if (scalarType === 'String') {
|
|
411
446
|
return [
|
|
412
|
-
'equals',
|
|
413
|
-
'
|
|
414
|
-
'notIn',
|
|
415
|
-
'lt',
|
|
416
|
-
'lte',
|
|
417
|
-
'gt',
|
|
418
|
-
'gte',
|
|
419
|
-
'contains',
|
|
420
|
-
'startsWith',
|
|
421
|
-
'endsWith',
|
|
422
|
-
'mode',
|
|
423
|
-
'not',
|
|
447
|
+
'equals', 'in', 'notIn', 'lt', 'lte', 'gt', 'gte',
|
|
448
|
+
'contains', 'startsWith', 'endsWith', 'mode', 'not',
|
|
424
449
|
]
|
|
425
450
|
}
|
|
426
|
-
|
|
427
|
-
if (
|
|
428
|
-
scalarType === 'Int' ||
|
|
429
|
-
scalarType === 'BigInt' ||
|
|
430
|
-
scalarType === 'Float' ||
|
|
431
|
-
scalarType === 'Decimal'
|
|
432
|
-
) {
|
|
451
|
+
if (scalarType === 'Int' || scalarType === 'BigInt' || scalarType === 'Float' || scalarType === 'Decimal') {
|
|
433
452
|
return ['equals', 'in', 'notIn', 'lt', 'lte', 'gt', 'gte', 'not']
|
|
434
453
|
}
|
|
435
|
-
|
|
436
|
-
if (scalarType === '
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
if (scalarType === 'Boolean') {
|
|
441
|
-
return ['equals', 'not']
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
if (scalarType === 'Json') {
|
|
445
|
-
return ['equals', 'path', 'string_contains', 'array_contains', 'not']
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
if (scalarType === 'Bytes') {
|
|
449
|
-
return ['equals', 'in', 'notIn', 'not']
|
|
450
|
-
}
|
|
451
|
-
|
|
454
|
+
if (scalarType === 'DateTime') return ['equals', 'in', 'notIn', 'lt', 'lte', 'gt', 'gte', 'not']
|
|
455
|
+
if (scalarType === 'Boolean') return ['equals', 'not']
|
|
456
|
+
if (scalarType === 'Json') return ['equals', 'path', 'string_contains', 'array_contains', 'not']
|
|
457
|
+
if (scalarType === 'Bytes') return ['equals', 'in', 'notIn', 'not']
|
|
452
458
|
return ['equals', 'in', 'notIn', 'not']
|
|
453
459
|
}
|
|
454
460
|
|
|
@@ -465,27 +471,11 @@ function whereFieldKind(f: FieldMeta): string {
|
|
|
465
471
|
|
|
466
472
|
function whereFieldShape(f: FieldMeta): string {
|
|
467
473
|
const kind = whereFieldKind(f)
|
|
468
|
-
|
|
469
|
-
if (kind === 'relation-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
if (kind === 'relation-single') {
|
|
474
|
-
return '{ is?: RelatedWhere, isNot?: RelatedWhere }'
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
if (kind === 'scalar-list') {
|
|
478
|
-
return '{ has?, hasEvery?, hasSome?, isEmpty? }'
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
if (kind === 'scalar') {
|
|
482
|
-
return 'scalar value OR filter object'
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
if (kind === 'enum') {
|
|
486
|
-
return 'enum value OR enum filter'
|
|
487
|
-
}
|
|
488
|
-
|
|
474
|
+
if (kind === 'relation-list') return '{ some?: RelatedWhere, every?: RelatedWhere, none?: RelatedWhere }'
|
|
475
|
+
if (kind === 'relation-single') return '{ is?: RelatedWhere, isNot?: RelatedWhere }'
|
|
476
|
+
if (kind === 'scalar-list') return '{ has?, hasEvery?, hasSome?, isEmpty? }'
|
|
477
|
+
if (kind === 'scalar') return 'scalar value OR filter object'
|
|
478
|
+
if (kind === 'enum') return 'enum value OR enum filter'
|
|
489
479
|
return 'n/a'
|
|
490
480
|
}
|
|
491
481
|
|
|
@@ -540,7 +530,12 @@ function buildFullPath(basePath: string, suffix: string): string {
|
|
|
540
530
|
return basePath ? basePath + suffix : suffix
|
|
541
531
|
}
|
|
542
532
|
|
|
543
|
-
export function renderDocs(
|
|
533
|
+
export function renderDocs(
|
|
534
|
+
modelName: string,
|
|
535
|
+
config: DocsConfig,
|
|
536
|
+
ctx: DocsModelContext,
|
|
537
|
+
writeStrategy?: WriteStrategy,
|
|
538
|
+
): string {
|
|
544
539
|
const title = config.docsTitle || modelName + ' API'
|
|
545
540
|
const generatedAt = new Date().toISOString()
|
|
546
541
|
|
|
@@ -559,8 +554,9 @@ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModel
|
|
|
559
554
|
|
|
560
555
|
const getOps = OPERATION_DEFS
|
|
561
556
|
.filter((d) => isOperationEnabled(config as Record<string, any>, d))
|
|
557
|
+
.filter((d) => !isOpHiddenByStrategy(d.name, writeStrategy))
|
|
562
558
|
.map((d) => {
|
|
563
|
-
const detail =
|
|
559
|
+
const detail = detailForOp(d.name, writeStrategy)
|
|
564
560
|
return {
|
|
565
561
|
op: d.name,
|
|
566
562
|
method: d.method.toUpperCase(),
|
|
@@ -627,12 +623,8 @@ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModel
|
|
|
627
623
|
if (firstStringField) {
|
|
628
624
|
andClauses.push({ [firstStringField.name]: { contains: 'example', mode: 'insensitive' } })
|
|
629
625
|
}
|
|
630
|
-
if (andClauses.length > 0)
|
|
631
|
-
|
|
632
|
-
}
|
|
633
|
-
if (firstBooleanField) {
|
|
634
|
-
whereExample.OR = [{ [firstBooleanField.name]: { equals: true } }]
|
|
635
|
-
}
|
|
626
|
+
if (andClauses.length > 0) whereExample.AND = andClauses
|
|
627
|
+
if (firstBooleanField) whereExample.OR = [{ [firstBooleanField.name]: { equals: true } }]
|
|
636
628
|
|
|
637
629
|
const selectExample: any = {}
|
|
638
630
|
for (const f of scalarFields.slice(0, 10)) selectExample[f.name] = true
|
|
@@ -644,9 +636,7 @@ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModel
|
|
|
644
636
|
const omitCandidates = scalarFields.filter((f) => !f.isId && !f.isUnique)
|
|
645
637
|
for (const f of omitCandidates.slice(0, 3)) omitExample[f.name] = true
|
|
646
638
|
|
|
647
|
-
const orderByField = firstUnique
|
|
648
|
-
? firstUnique.name
|
|
649
|
-
: firstFilterFieldName
|
|
639
|
+
const orderByField = firstUnique ? firstUnique.name : firstFilterFieldName
|
|
650
640
|
|
|
651
641
|
const findManyQueryArgs: any = {
|
|
652
642
|
where: whereExample,
|
|
@@ -698,9 +688,7 @@ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModel
|
|
|
698
688
|
: null
|
|
699
689
|
if (updateBodyExample) {
|
|
700
690
|
const firstEditableString = scalarFields.find((sf) => sf.type === 'String' && !sf.isId)
|
|
701
|
-
if (firstEditableString)
|
|
702
|
-
updateBodyExample.data[firstEditableString.name] = 'updated'
|
|
703
|
-
}
|
|
691
|
+
if (firstEditableString) updateBodyExample.data[firstEditableString.name] = 'updated'
|
|
704
692
|
}
|
|
705
693
|
|
|
706
694
|
const updateFetchExample = updateBodyExample
|
|
@@ -746,11 +734,7 @@ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModel
|
|
|
746
734
|
} else {
|
|
747
735
|
writeContract = 'Optional (nullable)'
|
|
748
736
|
}
|
|
749
|
-
return {
|
|
750
|
-
name: f.name,
|
|
751
|
-
type: describeFieldType(f),
|
|
752
|
-
writeContract,
|
|
753
|
-
}
|
|
737
|
+
return { name: f.name, type: describeFieldType(f), writeContract }
|
|
754
738
|
})
|
|
755
739
|
|
|
756
740
|
const argsReferenceRead = {
|
|
@@ -817,7 +801,7 @@ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModel
|
|
|
817
801
|
},
|
|
818
802
|
}
|
|
819
803
|
|
|
820
|
-
const
|
|
804
|
+
const argsReferenceWriteBase: Record<string, Record<string, string>> = {
|
|
821
805
|
create: {
|
|
822
806
|
data: modelName + 'CreateInput (required)',
|
|
823
807
|
select: 'Select',
|
|
@@ -867,19 +851,51 @@ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModel
|
|
|
867
851
|
include: 'Include',
|
|
868
852
|
omit: 'Omit',
|
|
869
853
|
},
|
|
870
|
-
deleteMany: {
|
|
871
|
-
where: 'WhereInput (required)',
|
|
872
|
-
},
|
|
854
|
+
deleteMany: { where: 'WhereInput (required)' },
|
|
873
855
|
}
|
|
874
856
|
|
|
857
|
+
const argsReferenceWrite: Record<string, Record<string, string>> = (() => {
|
|
858
|
+
if (writeStrategy === 'throwOnNonReturning') {
|
|
859
|
+
const { createMany, updateMany, ...rest } = argsReferenceWriteBase
|
|
860
|
+
return rest
|
|
861
|
+
}
|
|
862
|
+
if (writeStrategy === 'forceReturn') {
|
|
863
|
+
return {
|
|
864
|
+
...argsReferenceWriteBase,
|
|
865
|
+
createMany: {
|
|
866
|
+
data: modelName + 'CreateManyInput[] (required, scalar-only)',
|
|
867
|
+
skipDuplicates: 'boolean',
|
|
868
|
+
select: 'Select',
|
|
869
|
+
include: 'Include',
|
|
870
|
+
omit: 'Omit',
|
|
871
|
+
},
|
|
872
|
+
updateMany: {
|
|
873
|
+
where: 'WhereInput (required)',
|
|
874
|
+
data: modelName + 'UpdateManyMutationInput (required, scalar-only)',
|
|
875
|
+
select: 'Select',
|
|
876
|
+
include: 'Include',
|
|
877
|
+
omit: 'Omit',
|
|
878
|
+
},
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return argsReferenceWriteBase
|
|
882
|
+
})()
|
|
883
|
+
|
|
884
|
+
const batchMutationNote =
|
|
885
|
+
writeStrategy === 'forceReturn'
|
|
886
|
+
? 'Batch mutations: deleteMany returns { count }. createMany and updateMany silently invoke their returning counterparts and return arrays of records. Batch data inputs are scalar-only — nested relation writes are not supported.'
|
|
887
|
+
: writeStrategy === 'throwOnNonReturning'
|
|
888
|
+
? 'Batch mutations: deleteMany returns { count }. createMany and updateMany are disabled and return 501 if called directly; use the /many/return variants. Batch data inputs are scalar-only — nested relation writes are not supported.'
|
|
889
|
+
: 'Batch mutations (createMany, updateMany, deleteMany) return { count }. Batch data inputs are scalar-only — nested relation writes are not supported.'
|
|
890
|
+
|
|
875
891
|
const transportNotes = [
|
|
876
892
|
'GET endpoints: Prisma args as JSON-encoded query parameter strings via encodeQueryParams.',
|
|
877
893
|
'POST/PUT/DELETE/PATCH endpoints: Prisma args as JSON request body. Body must be a JSON object.',
|
|
878
894
|
'POST read endpoints: All read operations also accept POST with the same args as JSON body instead of query params. Use when query parameters exceed URL length limits. findMany uses POST /read, all others use the same path as GET.',
|
|
879
895
|
'findManyPaginated returns { data, total, hasMore }. hasMore is reliable for forward offset pagination only.',
|
|
880
|
-
|
|
896
|
+
batchMutationNote,
|
|
881
897
|
'findUnique and findFirst return null (not 404) when no record matches. Use the OrThrow variants for 404 behavior.',
|
|
882
|
-
'createManyAndReturn requires Prisma 5.14.0+, updateManyAndReturn requires Prisma 6.2.0+. Both are limited to PostgreSQL
|
|
898
|
+
'createManyAndReturn requires Prisma 5.14.0+, updateManyAndReturn requires Prisma 6.2.0+. Both are limited to PostgreSQL/CockroachDB/SQLite.',
|
|
883
899
|
]
|
|
884
900
|
|
|
885
901
|
const errorRows = [
|
|
@@ -887,8 +903,8 @@ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModel
|
|
|
887
903
|
{ status: '403', description: 'Forbidden', causes: 'Guard policy rejected the operation.' },
|
|
888
904
|
{ status: '404', description: 'Not found', causes: 'Record not found. Only from OrThrow operations, update, and delete.' },
|
|
889
905
|
{ status: '409', description: 'Conflict', causes: 'Unique constraint violation on create/update/upsert, or transaction conflict (e.g. in findManyPaginated).' },
|
|
890
|
-
{ status: '500', description: 'Internal server error', causes: 'Database error, table/column missing, raw query failure, or
|
|
891
|
-
{ status: '501', description: 'Not implemented', causes: 'Database provider does not support the requested feature.' },
|
|
906
|
+
{ status: '500', description: 'Internal server error', causes: 'Database error, table/column missing, raw query failure, unhandled error, or findManyPaginatedMode="transaction" without transaction support on the Prisma client.' },
|
|
907
|
+
{ status: '501', description: 'Not implemented', causes: 'Database provider does not support the requested feature, or writeStrategy disabled the endpoint.' },
|
|
892
908
|
{ status: '503', description: 'Service unavailable', causes: 'Database connection pool timeout.' },
|
|
893
909
|
]
|
|
894
910
|
|
|
@@ -903,6 +919,15 @@ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModel
|
|
|
903
919
|
'<a class="' + chipClass + '" href="?ui=yaml">YAML</a>' +
|
|
904
920
|
(hasPlayground ? '<a class="' + chipClass + '" href="?ui=playground">Playground</a>' : '')
|
|
905
921
|
|
|
922
|
+
const writeStrategyBanner = !writeStrategy || writeStrategy === 'regular'
|
|
923
|
+
? ''
|
|
924
|
+
: '<div class="mt-3 p-3 rounded-xl border border-amber-300 bg-amber-50 text-xs">' +
|
|
925
|
+
'<strong>writeStrategy = ' + escapeHtml(writeStrategy) + '.</strong> ' +
|
|
926
|
+
(writeStrategy === 'throwOnNonReturning'
|
|
927
|
+
? 'POST /many (createMany) and PUT /many (updateMany) are disabled and respond with 501. Use the /many/return variants.'
|
|
928
|
+
: 'POST /many silently invokes createManyAndReturn; PUT /many silently invokes updateManyAndReturn. Both return arrays of records instead of { count } and accept select/include/omit.') +
|
|
929
|
+
'</div>'
|
|
930
|
+
|
|
906
931
|
const tocHtml = '<ol class="m-0 pl-[18px]">' + anchors().map((a) => '<li class="my-1.5"><a href="#' + escapeHtml(a.id) + '" class="text-inherit">' + escapeHtml(a.label) + '</a></li>').join('') + '</ol>'
|
|
907
932
|
|
|
908
933
|
const whereRows = ctx.fields.map((f) => {
|
|
@@ -997,12 +1022,19 @@ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModel
|
|
|
997
1022
|
'Forced values (literals instead of true) are injected server-side and cannot be overridden by the client.',
|
|
998
1023
|
]
|
|
999
1024
|
|
|
1025
|
+
const writeStrategyNotes = !writeStrategy || writeStrategy === 'regular'
|
|
1026
|
+
? []
|
|
1027
|
+
: writeStrategy === 'throwOnNonReturning'
|
|
1028
|
+
? ['<strong>writeStrategy="throwOnNonReturning":</strong> the createMany (POST /many) and updateMany (PUT /many) endpoints are disabled and return 501. Use the corresponding <span class="font-mono">/many/return</span> endpoints instead.']
|
|
1029
|
+
: ['<strong>writeStrategy="forceReturn":</strong> the createMany (POST /many) and updateMany (PUT /many) endpoints silently invoke createManyAndReturn and updateManyAndReturn respectively. They return arrays of records instead of <span class="font-mono">{ count }</span>, and their request bodies accept <span class="font-mono">select</span>, <span class="font-mono">include</span>, and <span class="font-mono">omit</span>.']
|
|
1030
|
+
|
|
1000
1031
|
const runtimeNotes = [
|
|
1001
1032
|
'<strong>Query parameter parsing:</strong> GET query values are parsed server-side. Strings starting with <span class="font-mono">{</span>, <span class="font-mono">[</span>, or <span class="font-mono">"</span> are JSON-parsed. The strings <span class="font-mono">true</span>, <span class="font-mono">false</span>, <span class="font-mono">null</span> are converted to their JS equivalents. Numeric conversion only applies to <span class="font-mono">take</span> and <span class="font-mono">skip</span>. Use <span class="font-mono">encodeQueryParams</span> to avoid encoding issues.',
|
|
1002
1033
|
'<strong>POST read endpoints:</strong> All read operations accept POST as an alternative transport. The request body is a plain JSON object with the same argument structure as the GET query params — no JSON-string encoding needed. POST reads use native JSON types (numbers, booleans, objects) directly. findMany POST read is at <span class="font-mono">/read</span>; all other read operations use the same path as their GET counterpart. Disable with <span class="font-mono">disablePostReads: true</span> in route config.',
|
|
1003
1034
|
'<strong>Request body validation:</strong> All write endpoints require a JSON object body. Sending <span class="font-mono">null</span>, arrays, or non-object JSON values returns 400.',
|
|
1004
1035
|
'<strong>Documentation in production:</strong> Docs endpoints are disabled by default when <span class="font-mono">NODE_ENV=production</span> or <span class="font-mono">DISABLE_OPENAPI=true</span>. To enable in production, set <span class="font-mono">disableOpenApi: false</span> in the route config.',
|
|
1005
|
-
'<strong>Paginated query
|
|
1036
|
+
'<strong>Paginated query execution:</strong> findManyPaginated execution is controlled by the generator option <span class="font-mono">findManyPaginatedMode</span>. The default <span class="font-mono">"promiseAll"</span> runs data and count with <span class="font-mono">Promise.all</span> — faster, but not atomic under concurrent writes (data and total may be slightly inconsistent). <span class="font-mono">"transaction"</span> runs data and count inside an interactive transaction and throws <span class="font-mono">500</span> if the Prisma client does not support transactions. There is no implicit fallback in transaction mode.',
|
|
1037
|
+
'<strong>Materialized count source:</strong> findManyPaginated supports endpoint-level <span class="font-mono">pagination.countSource.type="materializedView"</span> for counts driven by a materialized view. The view query is used only when the request has no dynamic <span class="font-mono">where</span>, no <span class="font-mono">distinct</span>, and no guard shape — any of those falls back to the delegate count to keep the total consistent with the filtered data. The generated SQL uses PostgreSQL-style <span class="font-mono">$N</span> placeholders (PostgreSQL and CockroachDB). The optional <span class="font-mono">countSource.where</span> field supports flat equality and <span class="font-mono">null</span> only — no operators, no nested objects.',
|
|
1006
1038
|
'<strong>Distinct count approximation:</strong> When findManyPaginated is used with distinct and the number of unique values exceeds 100,000, the total falls back to a non-distinct count which may overcount. When a guard shape is configured alongside distinct, the total also falls back to a non-distinct count to avoid imposing the public read shape on the internal counting query. The hasMore value is affected accordingly.',
|
|
1007
1039
|
'<strong>Serialization:</strong> BigInt values are serialized as strings. Bytes/Buffer values are serialized as base64 strings. Decimal values are serialized as strings. DateTime values are serialized as ISO 8601 strings.',
|
|
1008
1040
|
'<strong>Playground:</strong> The query playground embeds an iframe to a local prisma-query-builder-ui instance. It connects to your real database using the configured DATABASE_URL. It is disabled in production and when queryBuilder is set to false or queryBuilder.enabled is set to false.',
|
|
@@ -1011,6 +1043,7 @@ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModel
|
|
|
1011
1043
|
'<strong>Bulk write constraints:</strong> createMany, createManyAndReturn, updateMany, and updateManyAndReturn accept scalar-only data inputs. Nested relation writes are not supported in these operations.',
|
|
1012
1044
|
'<strong>Provider compatibility:</strong> createManyAndReturn requires Prisma 5.14.0+ and is limited to PostgreSQL, CockroachDB, and SQLite. updateManyAndReturn requires Prisma 6.2.0+ with the same provider restrictions. skipDuplicates is not supported on all database providers.',
|
|
1013
1045
|
'<strong>omit compatibility:</strong> The omit parameter requires Prisma 6.2.0+. On versions 6.0.x–6.1.x, requests using omit return 400.',
|
|
1046
|
+
...writeStrategyNotes,
|
|
1014
1047
|
]
|
|
1015
1048
|
|
|
1016
1049
|
const noUniqueFieldNote = '<div class="bg-gray-50 border border-gray-200 rounded-xl p-3 text-xs text-gray-500">This model has no unique or id fields suitable for a generated example. Use the unique constraint from your schema.</div>'
|
|
@@ -1048,6 +1081,8 @@ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModel
|
|
|
1048
1081
|
<div class="flex gap-2 flex-wrap items-center pt-0.5">${openApiLinks}</div>
|
|
1049
1082
|
</div>
|
|
1050
1083
|
|
|
1084
|
+
${writeStrategyBanner}
|
|
1085
|
+
|
|
1051
1086
|
<div class="${calloutClass}">${tocHtml}</div>
|
|
1052
1087
|
|
|
1053
1088
|
<h2 class="mt-[18px] mb-2 text-base border-t border-gray-200 pt-3.5" id="ops">1. Operations</h2>
|
|
@@ -1274,9 +1309,7 @@ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModel
|
|
|
1274
1309
|
</div>
|
|
1275
1310
|
<div>
|
|
1276
1311
|
<h3 class="mt-3.5 mb-2 text-sm">11.3 GET — findUnique</h3>
|
|
1277
|
-
${findUniqueFetchExample
|
|
1278
|
-
? codeBlock(findUniqueFetchExample)
|
|
1279
|
-
: noUniqueFieldNote}
|
|
1312
|
+
${findUniqueFetchExample ? codeBlock(findUniqueFetchExample) : noUniqueFieldNote}
|
|
1280
1313
|
</div>
|
|
1281
1314
|
<div>
|
|
1282
1315
|
<h3 class="mt-3.5 mb-2 text-sm">11.4 POST — create</h3>
|
|
@@ -1284,21 +1317,15 @@ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModel
|
|
|
1284
1317
|
</div>
|
|
1285
1318
|
<div>
|
|
1286
1319
|
<h3 class="mt-3.5 mb-2 text-sm">11.5 PUT — update</h3>
|
|
1287
|
-
${updateFetchExample
|
|
1288
|
-
? codeBlock(updateFetchExample)
|
|
1289
|
-
: noUniqueFieldNote}
|
|
1320
|
+
${updateFetchExample ? codeBlock(updateFetchExample) : noUniqueFieldNote}
|
|
1290
1321
|
</div>
|
|
1291
1322
|
<div>
|
|
1292
1323
|
<h3 class="mt-3.5 mb-2 text-sm">11.6 DELETE — delete</h3>
|
|
1293
|
-
${deleteFetchExample
|
|
1294
|
-
? codeBlock(deleteFetchExample)
|
|
1295
|
-
: noUniqueFieldNote}
|
|
1324
|
+
${deleteFetchExample ? codeBlock(deleteFetchExample) : noUniqueFieldNote}
|
|
1296
1325
|
</div>
|
|
1297
1326
|
<div>
|
|
1298
1327
|
<h3 class="mt-3.5 mb-2 text-sm">11.7 Guard variant header</h3>
|
|
1299
|
-
${guardFetchExample
|
|
1300
|
-
? codeBlock(guardFetchExample)
|
|
1301
|
-
: noUniqueFieldNote}
|
|
1328
|
+
${guardFetchExample ? codeBlock(guardFetchExample) : noUniqueFieldNote}
|
|
1302
1329
|
</div>
|
|
1303
1330
|
</div>
|
|
1304
1331
|
|
|
@@ -5,6 +5,9 @@ import type {
|
|
|
5
5
|
ProgressiveStageResult,
|
|
6
6
|
ProgressiveStageContext,
|
|
7
7
|
ProgressiveStage,
|
|
8
|
+
PaginationConfig,
|
|
9
|
+
PaginationCountSource,
|
|
10
|
+
FindManyPaginatedMode,
|
|
8
11
|
} from './routeConfig'
|
|
9
12
|
|
|
10
13
|
export type {
|
|
@@ -13,12 +16,9 @@ export type {
|
|
|
13
16
|
ProgressiveStageResult,
|
|
14
17
|
ProgressiveStageContext,
|
|
15
18
|
ProgressiveStage,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
defaultLimit?: number
|
|
20
|
-
maxLimit?: number
|
|
21
|
-
distinctCountLimit?: number
|
|
19
|
+
PaginationConfig,
|
|
20
|
+
PaginationCountSource,
|
|
21
|
+
FindManyPaginatedMode,
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export interface OperationContext {
|
|
@@ -30,6 +30,7 @@ export interface OperationContext {
|
|
|
30
30
|
guardShape?: Record<string, unknown>
|
|
31
31
|
guardCaller?: string
|
|
32
32
|
paginationConfig?: PaginationConfig
|
|
33
|
+
findManyPaginatedMode?: FindManyPaginatedMode
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
export type PrismaDelegate = {
|
|
@@ -58,8 +59,14 @@ export type PrismaClientLike = {
|
|
|
58
59
|
$transaction?: <T>(fn: (tx: PrismaClientLike) => Promise<T>) => Promise<T>
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
type PrismaRawClient = {
|
|
63
|
+
$queryRawUnsafe?: <T = unknown>(sql: string, ...values: unknown[]) => Promise<T>
|
|
64
|
+
}
|
|
65
|
+
|
|
61
66
|
export const DISTINCT_COUNT_LIMIT = 100000
|
|
62
67
|
|
|
68
|
+
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
69
|
+
|
|
63
70
|
export class HttpError extends Error {
|
|
64
71
|
status: number
|
|
65
72
|
constructor(status: number, message: string) {
|
|
@@ -254,6 +261,14 @@ export function applyPaginationLimits(
|
|
|
254
261
|
return result
|
|
255
262
|
}
|
|
256
263
|
|
|
264
|
+
export function mergePaginationConfig(
|
|
265
|
+
base: PaginationConfig | undefined,
|
|
266
|
+
override: Partial<PaginationConfig> | undefined,
|
|
267
|
+
): PaginationConfig | undefined {
|
|
268
|
+
if (!base && !override) return undefined
|
|
269
|
+
return { ...(base ?? {}), ...(override ?? {}) }
|
|
270
|
+
}
|
|
271
|
+
|
|
257
272
|
export function normalizeDistinct(value: unknown): string[] {
|
|
258
273
|
if (typeof value === 'string') return [value]
|
|
259
274
|
if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string')
|
|
@@ -309,13 +324,85 @@ export function buildCountShape(
|
|
|
309
324
|
return result
|
|
310
325
|
}
|
|
311
326
|
|
|
327
|
+
function quoteIdent(name: string): string {
|
|
328
|
+
if (!IDENT_RE.test(name)) {
|
|
329
|
+
throw new HttpError(400, 'invalid identifier: ' + name)
|
|
330
|
+
}
|
|
331
|
+
return '"' + name.replace(/"/g, '""') + '"'
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function buildMaterializedCountFqn(
|
|
335
|
+
source: Extract<PaginationCountSource, { type: 'materializedView' }>,
|
|
336
|
+
): string {
|
|
337
|
+
return source.schema
|
|
338
|
+
? quoteIdent(source.schema) + '.' + quoteIdent(source.relation)
|
|
339
|
+
: quoteIdent(source.relation)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function buildMaterializedCountWhere(
|
|
343
|
+
where: Record<string, unknown> | undefined,
|
|
344
|
+
): { sql: string; values: unknown[] } {
|
|
345
|
+
if (!where || Object.keys(where).length === 0) {
|
|
346
|
+
return { sql: '', values: [] }
|
|
347
|
+
}
|
|
348
|
+
const values: unknown[] = []
|
|
349
|
+
const clauses: string[] = []
|
|
350
|
+
for (const [key, value] of Object.entries(where)) {
|
|
351
|
+
if (value === null) {
|
|
352
|
+
clauses.push(quoteIdent(key) + ' IS NULL')
|
|
353
|
+
continue
|
|
354
|
+
}
|
|
355
|
+
values.push(value)
|
|
356
|
+
clauses.push(quoteIdent(key) + ' = $' + values.length)
|
|
357
|
+
}
|
|
358
|
+
return { sql: ' WHERE ' + clauses.join(' AND '), values }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export async function countFromMaterializedView(
|
|
362
|
+
client: unknown,
|
|
363
|
+
source: Extract<PaginationCountSource, { type: 'materializedView' }>,
|
|
364
|
+
): Promise<number> {
|
|
365
|
+
const raw = client as PrismaRawClient
|
|
366
|
+
if (typeof raw.$queryRawUnsafe !== 'function') {
|
|
367
|
+
throw new HttpError(500, 'Materialized count source requires $queryRawUnsafe on the Prisma client')
|
|
368
|
+
}
|
|
369
|
+
const column = source.column ?? 'total'
|
|
370
|
+
const where = buildMaterializedCountWhere(source.where)
|
|
371
|
+
const sql =
|
|
372
|
+
'SELECT ' +
|
|
373
|
+
quoteIdent(column) +
|
|
374
|
+
' AS "total" FROM ' +
|
|
375
|
+
buildMaterializedCountFqn(source) +
|
|
376
|
+
where.sql +
|
|
377
|
+
' LIMIT 1'
|
|
378
|
+
const rows = await raw.$queryRawUnsafe<Array<{ total: unknown }>>(sql, ...where.values)
|
|
379
|
+
const value = rows[0]?.total
|
|
380
|
+
const total = Number(value)
|
|
381
|
+
if (!Number.isFinite(total)) {
|
|
382
|
+
throw new HttpError(500, 'Materialized count source did not return a numeric total')
|
|
383
|
+
}
|
|
384
|
+
return Math.trunc(total)
|
|
385
|
+
}
|
|
386
|
+
|
|
312
387
|
export async function countForPagination(
|
|
313
388
|
delegate: PrismaDelegate,
|
|
314
389
|
query: Record<string, unknown>,
|
|
315
390
|
shape: Record<string, unknown> | undefined,
|
|
316
391
|
caller: string | undefined,
|
|
317
392
|
distinctCountLimit?: number,
|
|
393
|
+
countSource?: PaginationCountSource,
|
|
394
|
+
rawClient?: unknown,
|
|
318
395
|
): Promise<number> {
|
|
396
|
+
if (
|
|
397
|
+
countSource &&
|
|
398
|
+
countSource.type === 'materializedView' &&
|
|
399
|
+
!shape &&
|
|
400
|
+
!query.where &&
|
|
401
|
+
!query.distinct
|
|
402
|
+
) {
|
|
403
|
+
return countFromMaterializedView(rawClient ?? delegate, countSource)
|
|
404
|
+
}
|
|
405
|
+
|
|
319
406
|
const distinctFields = normalizeDistinct(query.distinct)
|
|
320
407
|
const hasDistinct = distinctFields.length > 0
|
|
321
408
|
const effectiveLimit = distinctCountLimit ?? DISTINCT_COUNT_LIMIT
|
|
@@ -333,9 +420,7 @@ export async function countForPagination(
|
|
|
333
420
|
return (await delegate.count(countArgs)) as number
|
|
334
421
|
}
|
|
335
422
|
|
|
336
|
-
if (hasDistinct && shape)
|
|
337
|
-
return runCount()
|
|
338
|
-
}
|
|
423
|
+
if (hasDistinct && shape) return runCount()
|
|
339
424
|
|
|
340
425
|
if (hasDistinct) {
|
|
341
426
|
const selectField = distinctFields[0]
|
|
@@ -7,12 +7,20 @@ import type {
|
|
|
7
7
|
QueryBuilderConfig,
|
|
8
8
|
OpenApiServerConfig,
|
|
9
9
|
OpenApiSecuritySchemeConfig,
|
|
10
|
+
WriteStrategy,
|
|
11
|
+
FindManyPaginatedMode,
|
|
12
|
+
PaginationConfig,
|
|
13
|
+
PaginationCountSource,
|
|
10
14
|
} from './routeConfig'
|
|
11
15
|
|
|
12
16
|
export type {
|
|
13
17
|
QueryBuilderConfig,
|
|
14
18
|
OpenApiServerConfig,
|
|
15
19
|
OpenApiSecuritySchemeConfig,
|
|
20
|
+
WriteStrategy,
|
|
21
|
+
FindManyPaginatedMode,
|
|
22
|
+
PaginationConfig,
|
|
23
|
+
PaginationCountSource,
|
|
16
24
|
}
|
|
17
25
|
|
|
18
26
|
export type {
|
|
@@ -5,12 +5,20 @@ import type {
|
|
|
5
5
|
QueryBuilderConfig,
|
|
6
6
|
OpenApiServerConfig,
|
|
7
7
|
OpenApiSecuritySchemeConfig,
|
|
8
|
+
WriteStrategy,
|
|
9
|
+
FindManyPaginatedMode,
|
|
10
|
+
PaginationConfig,
|
|
11
|
+
PaginationCountSource,
|
|
8
12
|
} from './routeConfig'
|
|
9
13
|
|
|
10
14
|
export type {
|
|
11
15
|
QueryBuilderConfig,
|
|
12
16
|
OpenApiServerConfig,
|
|
13
17
|
OpenApiSecuritySchemeConfig,
|
|
18
|
+
WriteStrategy,
|
|
19
|
+
FindManyPaginatedMode,
|
|
20
|
+
PaginationConfig,
|
|
21
|
+
PaginationCountSource,
|
|
14
22
|
}
|
|
15
23
|
|
|
16
24
|
export type FastifyHookHandler = (
|
|
@@ -5,12 +5,20 @@ import type {
|
|
|
5
5
|
QueryBuilderConfig,
|
|
6
6
|
OpenApiServerConfig,
|
|
7
7
|
OpenApiSecuritySchemeConfig,
|
|
8
|
+
WriteStrategy,
|
|
9
|
+
FindManyPaginatedMode,
|
|
10
|
+
PaginationConfig,
|
|
11
|
+
PaginationCountSource,
|
|
8
12
|
} from './routeConfig'
|
|
9
13
|
|
|
10
14
|
export type {
|
|
11
15
|
QueryBuilderConfig,
|
|
12
16
|
OpenApiServerConfig,
|
|
13
17
|
OpenApiSecuritySchemeConfig,
|
|
18
|
+
WriteStrategy,
|
|
19
|
+
FindManyPaginatedMode,
|
|
20
|
+
PaginationConfig,
|
|
21
|
+
PaginationCountSource,
|
|
14
22
|
}
|
|
15
23
|
|
|
16
24
|
export type HonoEnvBase = {
|
|
@@ -24,7 +32,7 @@ export type HonoInternalVariables = {
|
|
|
24
32
|
sqlite?: unknown
|
|
25
33
|
parsedQuery?: Record<string, unknown>
|
|
26
34
|
body?: unknown
|
|
27
|
-
routeConfig?: { pagination?:
|
|
35
|
+
routeConfig?: { pagination?: PaginationConfig }
|
|
28
36
|
guardShape?: Record<string, unknown>
|
|
29
37
|
guardCaller?: string
|
|
30
38
|
resultData?: unknown
|
package/src/copy/routeConfig.ts
CHANGED
|
@@ -20,6 +20,27 @@ export interface OpenApiSecuritySchemeConfig {
|
|
|
20
20
|
description?: string
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
export type WriteStrategy = 'regular' | 'throwOnNonReturning' | 'forceReturn'
|
|
24
|
+
|
|
25
|
+
export type FindManyPaginatedMode = 'transaction' | 'promiseAll'
|
|
26
|
+
|
|
27
|
+
export type PaginationCountSource =
|
|
28
|
+
| { type?: 'delegate' }
|
|
29
|
+
| {
|
|
30
|
+
type: 'materializedView'
|
|
31
|
+
relation: string
|
|
32
|
+
schema?: string
|
|
33
|
+
column?: string
|
|
34
|
+
where?: Record<string, unknown>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PaginationConfig {
|
|
38
|
+
defaultLimit?: number
|
|
39
|
+
maxLimit?: number
|
|
40
|
+
distinctCountLimit?: number
|
|
41
|
+
countSource?: PaginationCountSource
|
|
42
|
+
}
|
|
43
|
+
|
|
23
44
|
export type ProgressivePatch = {
|
|
24
45
|
key: string
|
|
25
46
|
value: unknown
|
|
@@ -70,6 +91,7 @@ export interface BaseOperationConfig<HookHandler, TShape = Record<string, unknow
|
|
|
70
91
|
before?: HookHandler[]
|
|
71
92
|
after?: HookHandler[]
|
|
72
93
|
shape?: TShape
|
|
94
|
+
pagination?: Partial<PaginationConfig>
|
|
73
95
|
}
|
|
74
96
|
|
|
75
97
|
export interface BaseRouteConfig<
|
|
@@ -97,11 +119,7 @@ export interface BaseRouteConfig<
|
|
|
97
119
|
}
|
|
98
120
|
resolveContext?: (request: RequestType) => TCtx | Promise<TCtx>
|
|
99
121
|
queryBuilder?: QueryBuilderConfig | false
|
|
100
|
-
pagination?:
|
|
101
|
-
defaultLimit?: number
|
|
102
|
-
maxLimit?: number
|
|
103
|
-
distinctCountLimit?: number
|
|
104
|
-
}
|
|
122
|
+
pagination?: PaginationConfig
|
|
105
123
|
findUnique?: BaseOperationConfig<HookHandler, TShape>
|
|
106
124
|
findUniqueOrThrow?: BaseOperationConfig<HookHandler, TShape>
|
|
107
125
|
findFirst?: BaseOperationConfig<HookHandler, TShape>
|