prisma-generator-express 1.49.1 → 1.51.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 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/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.49.1",
4
+ "version": "1.51.0",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "license": "MIT",
@@ -21,6 +21,7 @@ type ViewDef = {
21
21
  defaultLimit?: number
22
22
  maxLimit?: number
23
23
  orderBy?: OrderByDef
24
+ allowedOrderBy?: string[]
24
25
  authorize?: (
25
26
  req: Request,
26
27
  viewName: string,
@@ -102,6 +103,47 @@ const buildOrderBy = (orderBy?: OrderByDef): string => {
102
103
  )
103
104
  }
104
105
 
106
+ const parseOrderByParam = (raw: unknown): OrderByDef | undefined => {
107
+ if (raw === undefined || raw === null || raw === '') return undefined
108
+ if (typeof raw !== 'string') return undefined
109
+ if (raw.startsWith('{') || raw.startsWith('[')) {
110
+ let parsed: unknown
111
+ try {
112
+ parsed = JSON.parse(raw)
113
+ } catch {
114
+ throw new HttpError(400, 'invalid orderBy JSON')
115
+ }
116
+ if (typeof parsed === 'string') return parsed
117
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
118
+ const keys = Object.keys(parsed as Record<string, unknown>)
119
+ if (keys.length === 0) return undefined
120
+ const field = keys[0]
121
+ const dirRaw = (parsed as Record<string, unknown>)[field]
122
+ if (dirRaw === 'asc' || dirRaw === 'desc')
123
+ return { field, direction: dirRaw }
124
+ if (dirRaw && typeof dirRaw === 'object') {
125
+ const sort = (dirRaw as { sort?: unknown }).sort
126
+ const nulls = (dirRaw as { nulls?: unknown }).nulls
127
+ const direction = sort === 'asc' || sort === 'desc' ? sort : undefined
128
+ const nullsOrder =
129
+ nulls === 'first' || nulls === 'last' ? nulls : undefined
130
+ return { field, direction, nulls: nullsOrder }
131
+ }
132
+ return { field }
133
+ }
134
+ throw new HttpError(400, 'invalid orderBy shape')
135
+ }
136
+ return raw
137
+ }
138
+
139
+ const enforceAllowlist = (orderBy: OrderByDef, allowed?: string[]): void => {
140
+ if (!allowed) return
141
+ const field = typeof orderBy === 'string' ? orderBy : orderBy.field
142
+ if (!allowed.includes(field)) {
143
+ throw new HttpError(400, 'orderBy field not allowed: ' + field)
144
+ }
145
+ }
146
+
105
147
  export const materializedViewsRouter = (
106
148
  opts: MaterializedRouterOptions,
107
149
  ): Router => {
@@ -130,7 +172,11 @@ export const materializedViewsRouter = (
130
172
  )
131
173
  const skip = clampInt(req.query.skip, 0, 0, Number.MAX_SAFE_INTEGER)
132
174
 
133
- if (skip > 0 && !def.orderBy) {
175
+ const queryOrderBy = parseOrderByParam(req.query.orderBy)
176
+ if (queryOrderBy) enforceAllowlist(queryOrderBy, def.allowedOrderBy)
177
+ const effectiveOrderBy = queryOrderBy ?? def.orderBy
178
+
179
+ if (skip > 0 && !effectiveOrderBy) {
134
180
  throw new HttpError(
135
181
  400,
136
182
  'skip requires orderBy for deterministic pagination',
@@ -140,7 +186,7 @@ export const materializedViewsRouter = (
140
186
  const sql =
141
187
  'SELECT * FROM ' +
142
188
  buildFqn(def) +
143
- buildOrderBy(def.orderBy) +
189
+ buildOrderBy(effectiveOrderBy) +
144
190
  ' LIMIT $1 OFFSET $2'
145
191
 
146
192
  const rows = await opts.prisma.$queryRawUnsafe<unknown[]>(