prisma-generator-express 1.49.0 → 1.50.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 +216 -0
- package/dist/utils/copyFiles.js +1 -0
- package/dist/utils/copyFiles.js.map +1 -1
- package/package.json +1 -1
- package/src/copy/materializedRouter.ts +67 -48
- package/src/utils/copyFiles.ts +1 -0
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@ Running `npx prisma generate` produces:
|
|
|
13
13
|
- Router generator with middleware support (before/after hooks per operation)
|
|
14
14
|
- POST read endpoints for all read operations (for complex queries exceeding URL length limits)
|
|
15
15
|
- Express-only progressive read streaming over Server-Sent Events (SSE), using manual stages or auto-include splitting for single-record relation reads
|
|
16
|
+
- Express-only standalone materialized view router for read-only access to registered PostgreSQL materialized views
|
|
16
17
|
- OpenAPI 3.1 spec (JSON and YAML endpoints registered automatically per router)
|
|
17
18
|
- Documentation helpers for contract view and Scalar UI (require manual mounting)
|
|
18
19
|
- Client-side query parameter encoder
|
|
@@ -34,6 +35,7 @@ Supports **Express**, **Fastify**, and **Hono** targets via the `target` configu
|
|
|
34
35
|
- [Request body format](#request-body-format)
|
|
35
36
|
- [Query encoding (client side)](#query-encoding-client-side)
|
|
36
37
|
- [POST read endpoints](#post-read-endpoints)
|
|
38
|
+
- [Materialized views router (Express)](#materialized-views-router-express)
|
|
37
39
|
- [Progressive Endpoint Composition (Express SSE)](#progressive-endpoint-composition-express-sse)
|
|
38
40
|
- [Response shaping: select, include, omit](#response-shaping-select-include-omit)
|
|
39
41
|
- [BigInt and Decimal handling](#bigint-and-decimal-handling)
|
|
@@ -1327,6 +1329,220 @@ app.use('/', UserRouter({
|
|
|
1327
1329
|
}))
|
|
1328
1330
|
```
|
|
1329
1331
|
|
|
1332
|
+
## Materialized views router (Express)
|
|
1333
|
+
|
|
1334
|
+
The Express target includes a standalone helper for read-only access to PostgreSQL materialized views.
|
|
1335
|
+
|
|
1336
|
+
Materialized views are not Prisma models and do not have Prisma delegates, so they are not generated as normal per-model routers. Instead, mount one standalone router and provide an explicit registry of allowed views.
|
|
1337
|
+
|
|
1338
|
+
This feature is **Express-only**.
|
|
1339
|
+
|
|
1340
|
+
### Usage
|
|
1341
|
+
|
|
1342
|
+
```ts
|
|
1343
|
+
import express from 'express'
|
|
1344
|
+
import { PrismaClient } from '@prisma/client'
|
|
1345
|
+
import { materializedViewsRouter } from './generated/materializedRouter'
|
|
1346
|
+
|
|
1347
|
+
const prisma = new PrismaClient()
|
|
1348
|
+
const app = express()
|
|
1349
|
+
|
|
1350
|
+
app.use('/api', materializedViewsRouter({
|
|
1351
|
+
prisma,
|
|
1352
|
+
basePath: '/materialized',
|
|
1353
|
+
views: {
|
|
1354
|
+
jobAdStats: {
|
|
1355
|
+
relation: 'mv_jobad_stats',
|
|
1356
|
+
orderBy: {
|
|
1357
|
+
field: 'updatedAt',
|
|
1358
|
+
direction: 'desc',
|
|
1359
|
+
},
|
|
1360
|
+
},
|
|
1361
|
+
companyStats: {
|
|
1362
|
+
relation: 'mv_company_stats',
|
|
1363
|
+
},
|
|
1364
|
+
},
|
|
1365
|
+
}))
|
|
1366
|
+
|
|
1367
|
+
app.listen(3000)
|
|
1368
|
+
```
|
|
1369
|
+
|
|
1370
|
+
This registers:
|
|
1371
|
+
|
|
1372
|
+
```http
|
|
1373
|
+
GET /api/materialized/jobAdStats?take=50&skip=0
|
|
1374
|
+
GET /api/materialized/companyStats?take=50
|
|
1375
|
+
```
|
|
1376
|
+
|
|
1377
|
+
### View registry
|
|
1378
|
+
|
|
1379
|
+
Each key in `views` is the public API name. The `relation` value is the actual database relation name.
|
|
1380
|
+
|
|
1381
|
+
```ts
|
|
1382
|
+
views: {
|
|
1383
|
+
jobAdStats: {
|
|
1384
|
+
relation: 'mv_jobad_stats',
|
|
1385
|
+
schema: 'public',
|
|
1386
|
+
defaultLimit: 50,
|
|
1387
|
+
maxLimit: 500,
|
|
1388
|
+
orderBy: {
|
|
1389
|
+
field: 'updatedAt',
|
|
1390
|
+
direction: 'desc',
|
|
1391
|
+
nulls: 'last',
|
|
1392
|
+
},
|
|
1393
|
+
},
|
|
1394
|
+
}
|
|
1395
|
+
```
|
|
1396
|
+
|
|
1397
|
+
Supported view options:
|
|
1398
|
+
|
|
1399
|
+
| Option | Type | Description |
|
|
1400
|
+
| ------ | ---- | ----------- |
|
|
1401
|
+
| `relation` | `string` | Database materialized view name |
|
|
1402
|
+
| `schema` | `string` | Optional schema name |
|
|
1403
|
+
| `defaultLimit` | `number` | Default page size for this view |
|
|
1404
|
+
| `maxLimit` | `number` | Maximum page size for this view |
|
|
1405
|
+
| `orderBy` | `string \| object` | Deterministic sort column |
|
|
1406
|
+
| `authorize` | `function` | Optional per-view authorization hook |
|
|
1407
|
+
|
|
1408
|
+
`orderBy` can be a string:
|
|
1409
|
+
|
|
1410
|
+
```ts
|
|
1411
|
+
orderBy: 'updatedAt'
|
|
1412
|
+
```
|
|
1413
|
+
|
|
1414
|
+
Or an object:
|
|
1415
|
+
|
|
1416
|
+
```ts
|
|
1417
|
+
orderBy: {
|
|
1418
|
+
field: 'updatedAt',
|
|
1419
|
+
direction: 'desc',
|
|
1420
|
+
nulls: 'last',
|
|
1421
|
+
}
|
|
1422
|
+
```
|
|
1423
|
+
|
|
1424
|
+
### Router options
|
|
1425
|
+
|
|
1426
|
+
```ts
|
|
1427
|
+
materializedViewsRouter({
|
|
1428
|
+
prisma,
|
|
1429
|
+
basePath: '/materialized',
|
|
1430
|
+
defaultLimit: 50,
|
|
1431
|
+
maxLimit: 1000,
|
|
1432
|
+
before: [authMiddleware],
|
|
1433
|
+
after: [auditMiddleware],
|
|
1434
|
+
views: {
|
|
1435
|
+
jobAdStats: { relation: 'mv_jobad_stats' },
|
|
1436
|
+
},
|
|
1437
|
+
})
|
|
1438
|
+
```
|
|
1439
|
+
|
|
1440
|
+
Supported router options:
|
|
1441
|
+
|
|
1442
|
+
| Option | Type | Description |
|
|
1443
|
+
| ------ | ---- | ----------- |
|
|
1444
|
+
| `prisma` | Prisma client-like object | Must expose `$queryRawUnsafe` |
|
|
1445
|
+
| `views` | `Record<string, ViewDef>` | Registry of allowed materialized views |
|
|
1446
|
+
| `basePath` | `string` | Path inside this router, default `''` |
|
|
1447
|
+
| `defaultLimit` | `number` | Global default page size, default `50` |
|
|
1448
|
+
| `maxLimit` | `number` | Global max page size, default `1000` |
|
|
1449
|
+
| `before` | `RequestHandler[]` | Express middleware before the query |
|
|
1450
|
+
| `after` | `RequestHandler[]` | Express middleware after the query |
|
|
1451
|
+
|
|
1452
|
+
### Authorization
|
|
1453
|
+
|
|
1454
|
+
Use `before` for shared middleware and `authorize` for per-view checks.
|
|
1455
|
+
|
|
1456
|
+
```ts
|
|
1457
|
+
const forbidden = (message: string) => Object.assign(new Error(message), { status: 403 })
|
|
1458
|
+
|
|
1459
|
+
app.use('/api', materializedViewsRouter({
|
|
1460
|
+
prisma,
|
|
1461
|
+
basePath: '/materialized',
|
|
1462
|
+
before: [requireAuth],
|
|
1463
|
+
views: {
|
|
1464
|
+
publicStats: {
|
|
1465
|
+
relation: 'mv_public_stats',
|
|
1466
|
+
},
|
|
1467
|
+
adminStats: {
|
|
1468
|
+
relation: 'mv_admin_stats',
|
|
1469
|
+
authorize: (req) => {
|
|
1470
|
+
if (req.user?.role !== 'admin') {
|
|
1471
|
+
throw forbidden('Forbidden')
|
|
1472
|
+
}
|
|
1473
|
+
},
|
|
1474
|
+
},
|
|
1475
|
+
},
|
|
1476
|
+
}))
|
|
1477
|
+
```
|
|
1478
|
+
|
|
1479
|
+
If a view is not in the registry, the router returns:
|
|
1480
|
+
|
|
1481
|
+
```json
|
|
1482
|
+
{ "message": "unknown view" }
|
|
1483
|
+
```
|
|
1484
|
+
|
|
1485
|
+
### Pagination
|
|
1486
|
+
|
|
1487
|
+
The router supports `take` and `skip` query parameters:
|
|
1488
|
+
|
|
1489
|
+
```http
|
|
1490
|
+
GET /materialized/jobAdStats?take=100&skip=200
|
|
1491
|
+
```
|
|
1492
|
+
|
|
1493
|
+
`take` is clamped to the configured max limit. `skip` is clamped to zero or greater.
|
|
1494
|
+
|
|
1495
|
+
When `skip > 0`, the view must define `orderBy`. This prevents unstable offset pagination.
|
|
1496
|
+
|
|
1497
|
+
```ts
|
|
1498
|
+
views: {
|
|
1499
|
+
jobAdStats: {
|
|
1500
|
+
relation: 'mv_jobad_stats',
|
|
1501
|
+
orderBy: {
|
|
1502
|
+
field: 'updatedAt',
|
|
1503
|
+
direction: 'desc',
|
|
1504
|
+
},
|
|
1505
|
+
},
|
|
1506
|
+
}
|
|
1507
|
+
```
|
|
1508
|
+
|
|
1509
|
+
### Identifier safety
|
|
1510
|
+
|
|
1511
|
+
Only registered view names can be queried. Database identifiers such as `schema`, `relation`, and `orderBy.field` must match this pattern:
|
|
1512
|
+
|
|
1513
|
+
```txt
|
|
1514
|
+
^[A-Za-z_][A-Za-z0-9_]*$
|
|
1515
|
+
```
|
|
1516
|
+
|
|
1517
|
+
Identifiers are double-quoted before being used in SQL.
|
|
1518
|
+
|
|
1519
|
+
This means camel-case database columns must exist as quoted identifiers. For example, `orderBy: 'updatedAt'` queries `"updatedAt"`. If the materialized view was created without a quoted alias, PostgreSQL stores the column as `updatedat`.
|
|
1520
|
+
|
|
1521
|
+
### Response serialization
|
|
1522
|
+
|
|
1523
|
+
Responses use the same serialization behavior as generated routers:
|
|
1524
|
+
|
|
1525
|
+
- `BigInt` values become strings
|
|
1526
|
+
- `Decimal` values become strings
|
|
1527
|
+
- `Buffer` and `Uint8Array` values become base64 strings
|
|
1528
|
+
- `DateTime` values become ISO 8601 strings
|
|
1529
|
+
|
|
1530
|
+
### Limitations
|
|
1531
|
+
|
|
1532
|
+
The materialized views router is intentionally small and read-only.
|
|
1533
|
+
|
|
1534
|
+
It does not support:
|
|
1535
|
+
|
|
1536
|
+
- Prisma `where`
|
|
1537
|
+
- Prisma `select`
|
|
1538
|
+
- Prisma `include`
|
|
1539
|
+
- Prisma guard shapes
|
|
1540
|
+
- OpenAPI generation
|
|
1541
|
+
- Fastify or Hono targets
|
|
1542
|
+
- refreshing materialized views
|
|
1543
|
+
|
|
1544
|
+
Use it for explicit read-only endpoints over known materialized views. For normal Prisma models, use the generated model routers.
|
|
1545
|
+
|
|
1330
1546
|
## Progressive Endpoint Composition (Express SSE)
|
|
1331
1547
|
|
|
1332
1548
|
Progressive Endpoint Composition lets an Express read endpoint stream partial response fields over Server-Sent Events while still ending with a final result event.
|
package/dist/utils/copyFiles.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"copyFiles.js","sourceRoot":"","sources":["../../src/utils/copyFiles.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"copyFiles.js","sourceRoot":"","sources":["../../src/utils/copyFiles.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prisma-generator-express",
|
|
3
3
|
"description": "Prisma generator for Express, Fastify, and Hono CRUD APIs with OpenAPI documentation",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.50.0",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"license": "MIT",
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import express from 'express'
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
NextFunction,
|
|
4
|
+
Request,
|
|
5
|
+
RequestHandler,
|
|
6
|
+
Response,
|
|
7
|
+
Router,
|
|
8
|
+
} from 'express'
|
|
3
9
|
import { HttpError, mapError, transformResult } from './operationRuntime'
|
|
4
10
|
|
|
5
11
|
type SortDirection = 'asc' | 'desc'
|
|
@@ -7,11 +13,7 @@ type NullsOrder = 'first' | 'last'
|
|
|
7
13
|
|
|
8
14
|
type OrderByDef =
|
|
9
15
|
| string
|
|
10
|
-
| {
|
|
11
|
-
field: string
|
|
12
|
-
direction?: SortDirection
|
|
13
|
-
nulls?: NullsOrder
|
|
14
|
-
}
|
|
16
|
+
| { field: string; direction?: SortDirection; nulls?: NullsOrder }
|
|
15
17
|
|
|
16
18
|
type ViewDef = {
|
|
17
19
|
relation: string
|
|
@@ -19,11 +21,18 @@ type ViewDef = {
|
|
|
19
21
|
defaultLimit?: number
|
|
20
22
|
maxLimit?: number
|
|
21
23
|
orderBy?: OrderByDef
|
|
22
|
-
authorize?: (
|
|
24
|
+
authorize?: (
|
|
25
|
+
req: Request,
|
|
26
|
+
viewName: string,
|
|
27
|
+
def: ViewDef,
|
|
28
|
+
) => void | Promise<void>
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
type PrismaRawClient = {
|
|
26
|
-
$queryRawUnsafe: <T = unknown>(
|
|
32
|
+
$queryRawUnsafe: <T = unknown>(
|
|
33
|
+
sql: string,
|
|
34
|
+
...values: unknown[]
|
|
35
|
+
) => Promise<T>
|
|
27
36
|
}
|
|
28
37
|
|
|
29
38
|
type MaterializedRouterOptions = {
|
|
@@ -39,7 +48,8 @@ type MaterializedRouterOptions = {
|
|
|
39
48
|
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
40
49
|
|
|
41
50
|
const quoteIdent = (name: string): string => {
|
|
42
|
-
if (!IDENT_RE.test(name))
|
|
51
|
+
if (!IDENT_RE.test(name))
|
|
52
|
+
throw new HttpError(400, 'invalid identifier: ' + name)
|
|
43
53
|
return '"' + name.replace(/"/g, '""') + '"'
|
|
44
54
|
}
|
|
45
55
|
|
|
@@ -54,7 +64,12 @@ const buildFqn = (def: ViewDef): string =>
|
|
|
54
64
|
? quoteIdent(def.schema) + '.' + quoteIdent(def.relation)
|
|
55
65
|
: quoteIdent(def.relation)
|
|
56
66
|
|
|
57
|
-
const clampInt = (
|
|
67
|
+
const clampInt = (
|
|
68
|
+
v: unknown,
|
|
69
|
+
fallback: number,
|
|
70
|
+
min: number,
|
|
71
|
+
max: number,
|
|
72
|
+
): number => {
|
|
58
73
|
const n = Number(v ?? fallback)
|
|
59
74
|
if (!Number.isFinite(n)) return fallback
|
|
60
75
|
return Math.min(Math.max(Math.trunc(n), min), max)
|
|
@@ -63,23 +78,21 @@ const clampInt = (v: unknown, fallback: number, min: number, max: number): numbe
|
|
|
63
78
|
const normalizeDirection = (value: unknown): 'ASC' | 'DESC' => {
|
|
64
79
|
if (value === undefined || value === 'asc' || value === 'ASC') return 'ASC'
|
|
65
80
|
if (value === 'desc' || value === 'DESC') return 'DESC'
|
|
66
|
-
throw new
|
|
81
|
+
throw new HttpError(400, 'invalid sort direction')
|
|
67
82
|
}
|
|
68
83
|
|
|
69
|
-
const normalizeNulls = (
|
|
84
|
+
const normalizeNulls = (
|
|
85
|
+
value: unknown,
|
|
86
|
+
): '' | ' NULLS FIRST' | ' NULLS LAST' => {
|
|
70
87
|
if (value === undefined) return ''
|
|
71
88
|
if (value === 'first' || value === 'FIRST') return ' NULLS FIRST'
|
|
72
89
|
if (value === 'last' || value === 'LAST') return ' NULLS LAST'
|
|
73
|
-
throw new
|
|
90
|
+
throw new HttpError(400, 'invalid nulls order')
|
|
74
91
|
}
|
|
75
92
|
|
|
76
93
|
const buildOrderBy = (orderBy?: OrderByDef): string => {
|
|
77
94
|
if (!orderBy) return ''
|
|
78
|
-
|
|
79
|
-
if (typeof orderBy === 'string') {
|
|
80
|
-
return ' ORDER BY ' + quoteIdent(orderBy)
|
|
81
|
-
}
|
|
82
|
-
|
|
95
|
+
if (typeof orderBy === 'string') return ' ORDER BY ' + quoteIdent(orderBy)
|
|
83
96
|
return (
|
|
84
97
|
' ORDER BY ' +
|
|
85
98
|
quoteIdent(orderBy.field) +
|
|
@@ -89,7 +102,9 @@ const buildOrderBy = (orderBy?: OrderByDef): string => {
|
|
|
89
102
|
)
|
|
90
103
|
}
|
|
91
104
|
|
|
92
|
-
export const materializedViewsRouter = (
|
|
105
|
+
export const materializedViewsRouter = (
|
|
106
|
+
opts: MaterializedRouterOptions,
|
|
107
|
+
): Router => {
|
|
93
108
|
const router = express.Router()
|
|
94
109
|
const basePath = normalizeBasePath(opts.basePath)
|
|
95
110
|
const defaultLimit = opts.defaultLimit ?? 50
|
|
@@ -104,14 +119,8 @@ export const materializedViewsRouter = (opts: MaterializedRouterOptions): Router
|
|
|
104
119
|
try {
|
|
105
120
|
const viewName = req.params.viewName
|
|
106
121
|
const def = opts.views[viewName]
|
|
107
|
-
|
|
108
|
-
if (
|
|
109
|
-
throw new HttpError(404, 'unknown view')
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (def.authorize) {
|
|
113
|
-
await def.authorize(req, viewName, def)
|
|
114
|
-
}
|
|
122
|
+
if (!def) throw new HttpError(404, 'unknown view')
|
|
123
|
+
if (def.authorize) await def.authorize(req, viewName, def)
|
|
115
124
|
|
|
116
125
|
const take = clampInt(
|
|
117
126
|
req.query.take,
|
|
@@ -119,11 +128,13 @@ export const materializedViewsRouter = (opts: MaterializedRouterOptions): Router
|
|
|
119
128
|
1,
|
|
120
129
|
def.maxLimit ?? maxLimit,
|
|
121
130
|
)
|
|
122
|
-
|
|
123
131
|
const skip = clampInt(req.query.skip, 0, 0, Number.MAX_SAFE_INTEGER)
|
|
124
132
|
|
|
125
133
|
if (skip > 0 && !def.orderBy) {
|
|
126
|
-
throw new HttpError(
|
|
134
|
+
throw new HttpError(
|
|
135
|
+
400,
|
|
136
|
+
'skip requires orderBy for deterministic pagination',
|
|
137
|
+
)
|
|
127
138
|
}
|
|
128
139
|
|
|
129
140
|
const sql =
|
|
@@ -132,8 +143,11 @@ export const materializedViewsRouter = (opts: MaterializedRouterOptions): Router
|
|
|
132
143
|
buildOrderBy(def.orderBy) +
|
|
133
144
|
' LIMIT $1 OFFSET $2'
|
|
134
145
|
|
|
135
|
-
const rows = await opts.prisma.$queryRawUnsafe<unknown[]>(
|
|
136
|
-
|
|
146
|
+
const rows = await opts.prisma.$queryRawUnsafe<unknown[]>(
|
|
147
|
+
sql,
|
|
148
|
+
take,
|
|
149
|
+
skip,
|
|
150
|
+
)
|
|
137
151
|
res.locals.data = transformResult(rows)
|
|
138
152
|
next()
|
|
139
153
|
} catch (err) {
|
|
@@ -146,23 +160,28 @@ export const materializedViewsRouter = (opts: MaterializedRouterOptions): Router
|
|
|
146
160
|
},
|
|
147
161
|
)
|
|
148
162
|
|
|
149
|
-
router.use(
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
(err as {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
+
router.use(
|
|
164
|
+
(err: unknown, _req: Request, res: Response, next: NextFunction) => {
|
|
165
|
+
const httpError =
|
|
166
|
+
err instanceof HttpError
|
|
167
|
+
? err
|
|
168
|
+
: err &&
|
|
169
|
+
typeof err === 'object' &&
|
|
170
|
+
typeof (err as { status?: number }).status === 'number'
|
|
171
|
+
? new HttpError(
|
|
172
|
+
(err as { status: number }).status,
|
|
173
|
+
(err as { message?: string }).message ||
|
|
174
|
+
'Internal server error',
|
|
175
|
+
)
|
|
176
|
+
: mapError(err)
|
|
177
|
+
|
|
178
|
+
if (!res.headersSent) {
|
|
179
|
+
return res.status(httpError.status).json({ message: httpError.message })
|
|
180
|
+
}
|
|
163
181
|
|
|
164
|
-
|
|
165
|
-
|
|
182
|
+
next(err)
|
|
183
|
+
},
|
|
184
|
+
)
|
|
166
185
|
|
|
167
186
|
return router
|
|
168
|
-
}
|
|
187
|
+
}
|