@didactika/prisma-autoread
v1.1.2
Published
Auto-generate Express search endpoints from your Prisma schema — GET/QUERY/POST, Prisma-native filtering, RSQL & OData, aggregations, cursor pagination and HAL/JSON:API/CSV output, zero boilerplate.
Downloads
491
Readme
prisma-autoread
Drop-in search endpoints for Express + Prisma. One declaration gives you filtering, sorting, field selection, relation includes, aggregations, pagination (offset and cursor) and multiple response formats — over
GET, the newQUERYmethod, orPOST. Query it as if you were writing Prisma.
createAutoRead({ model: 'User', delegate: prisma.user }).applyTo(router);GET /users?filter[age][gte]=30&sort=-createdAt&fields=id,firstName&page=2
QUERY /users { "where": { "age": { "gte": 30 } }, "orderBy": [{ "createdAt": "desc" }] }Table of contents
- Features
- Compatibility
- Installation
- Quick start
- HTTP methods
- Filtering
- Sorting, selection, includes
- Pagination
- Routes: list, count, aggregate, group-by
- Output formats
- Configuration
- Security
- Performance
- Error handling
- Migrating from 0.x (legacy)
- How it works
- License
Features
- 🔎 Prisma-native filtering — the query mirrors Prisma's
where, coerced and validated against your schema (DMMF). Nothing Prisma can express is lost. - 🧬 Multiple input protocols — bracket query strings, JSON bodies, RSQL/FIQL
and OData
$filter, all producing the same query. - 🌐 GET, QUERY & POST — including the new safe/idempotent
QUERYmethod (body-based search). - 🧮 Full grammar —
eq/ne/gt/gte/lt/lte/in/notIn/contains/startsWith/endsWith/mode/isNull,AND/OR/NOT, relationsome/every/none/is/isNot, and JSON path filters. - 🍃 MongoDB embedded documents — filter inside composite
typeblocks at any depth;is/somewrappers are inserted for you. - 📊 Aggregations —
count,aggregate(sum/avg/min/max) andgroup-by. - 📄 Pagination — offset (
page/limit) and cursor (cursor=), with links. - 🎨 Output formats — HAL (default), plain, JSON:API and CSV, with content negotiation.
- 🛡️ Security — field/relation allow-lists, hidden fields that never reach the response, strict deny-by-default mode, nesting guard.
- 🔤 Renameable keywords — a column called
fieldsorsort? Rename the control parameter. - 🧩 Framework-agnostic — Express, Fastify and Hono bindings (neither is a dependency).
- 🚀 Fast — O(1) schema lookups, single-pass parsing, optional query-plan cache, telemetry.
- 🔁 Backward compatible — the original middleware still ships and works unchanged.
- 📦 Typed — full TypeScript definitions, declared separately in
*.d.ts.
📚 Full documentation → — guides, query reference and UML diagrams.
Compatibility
Every combination below is exercised in CI on each push
(ci.yml).
| | Supported | Verified in CI |
|---|---|---|
| Node.js | 20 · 22 · 24 (the three current LTS lines) | full suite on each |
| Prisma | 5 · 6 · 7 | unit + integration on all; end-to-end on 5 and 6 |
| Express | 4 · 5 | full suite on both |
| Fastify | 4 · 5 | binding suite (not a dependency) |
| Hono | 4 | binding suite (not a dependency) |
| TypeScript | 5.x | build emits CJS + ESM + .d.ts |
- Express 4 vs 5 — Express 5 changed the default query parser to the flat one, which
would break bracket notation. The engine parses the query string itself, so both work
with no configuration. (Only the deprecated 0.x middleware, if you mount it directly,
needs
app.set('query parser', 'extended')on Express 5.) - Prisma 7 — Prisma 7 moved the datasource URL out of
schema.prismaintoprisma.config.tsand requires a driver adapter. That is a Prisma-level requirement of your app; this library readsPrisma.dmmf, which is unchanged, so it works on 5, 6 and 7 alike. - Fastify +
QUERY— register the method once before binding:fastify.addHttpMethod('QUERY', { hasBody: true }). - JSON filtering — advanced JSON filters are only supported by PostgreSQL and MySQL
(a Prisma limitation). The JSON
pathformat is detected from your datasource.
Peer dependencies
| Package | Range |
|---|---|
| @prisma/client | >= 5.0.0 |
| express | >= 4.0.0 |
Generate your Prisma Client (npx prisma generate) — the engine reads field and
relation metadata from Prisma.dmmf at runtime.
Installation
npm install @didactika/prisma-autoread
npm install @prisma/client express # peer dependenciesQuick start
import express, { Router } from 'express';
import { PrismaClient } from '@prisma/client';
import { createAutoRead } from '@didactika/prisma-autoread';
const prisma = new PrismaClient();
const app = express();
const router = Router();
createAutoRead({
model: 'User',
delegate: prisma.user, // enables list + count + aggregate + group-by
methods: ['GET', 'QUERY'],
routes: ['list', 'count'],
searchable: ['firstName', 'lastName', 'email'],
}).applyTo(router);
app.use('/users', router);
// Format thrown errors (they carry `.status`).
app.use((err: any, _req: any, res: any, _next: any) => {
res.status(err.status ?? 500).json({ error: err.message });
});
app.listen(3000);GET /users now supports filtering, sorting, selection, includes, search and
pagination; GET /users/count counts with the same filter.
HTTP methods
Set methods to choose the transports:
| Method | Input | Notes |
|---|---|---|
| GET | query string | Simple, cacheable, linkable. Default. |
| QUERY | JSON body | Safe & idempotent method with a body — for complex queries. |
| POST | JSON body | Fallback for clients/proxies that don't speak QUERY. |
GET reads a filter dialect (see below); QUERY/POST read a Prisma-shaped body:
// QUERY /users
{
"where": { "age": { "gte": 30 }, "OR": [{ "active": true }, { "age": { "lt": 18 } }] },
"orderBy": [{ "createdAt": "desc" }],
"select": { "id": true, "firstName": true },
"page": 1, "limit": 20
}Filtering
On GET with legacy: false, the filter lives under filter[…] and mirrors Prisma:
GET /users?filter[age][gte]=30
GET /users?filter[firstName][contains]=Al&filter[firstName][mode]=insensitive
GET /users?filter[id][in]=1,2,3Values are coerced to the column type from your schema; unknown fields return 400.
Operators
| Alias | Prisma | | Alias | Prisma |
|---|---|---|---|---|
| eq / equals | equals | | contains | contains |
| ne / not | not | | startsWith / sw | startsWith |
| gt gte lt lte | same | | endsWith / ew | endsWith |
| in | in | | mode | mode |
| nin / notIn | notIn | | isNull | equals: null / not: null |
A bare list is treated as in: filter[id]=1,2 → { id: { in: [1, 2] } }.
Logical groups, relations, JSON
# OR / AND / NOT
GET /users?filter[or][0][active]=true&filter[or][1][age][lt]=18
→ { OR: [ { active: true }, { age: { lt: 18 } } ] }
# Relations (to-many auto-wrapped in `some`; explicit some/every/none/is/isNot supported)
GET /users?filter[posts][some][title][startsWith]=Hello
GET /orders?filter[customer][email][contains]=@corp
# MongoDB embedded documents (composite `type` blocks), wrapped in `is` / `some`
GET /course-schedules?filter[program][shortname]=MAT
→ { program: { is: { shortname: 'MAT' } } }
GET /course-schedules?filter[program][subjects][type]=lab
→ { program: { is: { subjects: { some: { type: 'lab' } } } } }
# JSON columns (Prisma-native path filter)
GET /users?filter[metadata][path][0]=theme&filter[metadata][equals]=darkRSQL and OData
With legacy: false the GET dialects are auto-detected by shape, so these all work
on the same endpoint:
# RSQL / FIQL (; = AND, , = OR, * = wildcard)
GET /users?filter=age=ge=30;name==Al*
GET /users?filter=active==true,age=lt=18
GET /users?filter=role=in=(admin,editor)
# OData $filter
GET /users?$filter=age gt 30 and startswith(name,'Al')
GET /users?$filter=active eq true or age lt 18
GET /users?$orderby=age desc&$select=id,name&$top=20&$skip=40Restrict the accepted dialects with formats: ['query'] (or ['query','rsql'], …).
Sorting, selection, includes
GET /users?sort=-createdAt,lastName → orderBy: [{ createdAt: 'desc' }, { lastName: 'asc' }]
GET /users?fields=id,firstName,email → select: { id, firstName, email }
GET /users?include=posts[comments] → include posts with nested comments
GET /users?distinct=email → distinct: ['email']
GET /users?search=alice → OR-contains across `searchable` fields
fields(select) andincludeare mutually exclusive in Prisma — if both are given,selectwins.
Pagination
Offset (default):
GET /users?page=2&limit=20Cursor — pass the last id you saw; the response echoes the next one in
pagination.nextCursor and a next link:
GET /users?limit=20&cursor=42A cursor is a row identifier, not a row number: cursor=42 means "start after
the row with id 42", not "start at row 42" — for that, use page/limit. Follow
next until it disappears: on the last page nextCursor is absent and hasNext is
false. The value is validated against the id column, so a wrong-typed cursor
(including a malformed MongoDB @db.ObjectId) returns 400 naming the problem
instead of a driver error. Page by another unique column with ?cursor[uuid]=….
Routes
routes accepts a short form or a map with custom paths:
routes: ['list', 'count', 'aggregate', 'groupBy']
routes: { list: true, count: { path: '/total' } }| Route | Prisma | Default path | Example |
|---|---|---|---|
| list | findMany (+ count) | / | GET /users?filter[active]=true |
| count | count | /count | GET /users/count?filter[active]=true → { "count": 12 } |
| aggregate | aggregate | /aggregate | GET /users/aggregate?avg=age&count=true |
| groupBy | groupBy | /group-by | GET /users/group-by?by=role&count=true |
Aggregation params: sum, avg, min, max (field lists) and count (true or a
field list); group-by adds by and an optional Prisma-native having.
Output formats
hal (default), plain, jsonapi and csv are built in. Pick a default with
output, or let clients negotiate:
GET /users?format=csv
GET /users (Accept: application/vnd.api+json) → JSON:APIConfiguration
createAutoRead(options): { applyTo(router): Router }| Option | Required | Default | Description |
|---|:---:|---|---|
| model | ✅ | — | Prisma model name (schema casing). |
| delegate | ✅* | — | Prisma model delegate (prisma.user). Enables all routes. |
| findByFilter | ✅* | — | Legacy-style callback, alternative to delegate. *One of the two. |
| methods | | ['GET'] | HTTP methods to expose. |
| routes | | ['list'] | Routes to generate (short form or per-route path map). |
| output | | 'hal' | Default output format. |
| legacy | | true | true = old GET syntax; false = modern dialects. |
| formats | | all | GET dialects when legacy: false (query/rsql/odata). |
| searchable | | [] | Fields scanned by ?search=. |
| defaults | | {limit:10,maxLimit:100,sort:'id',order:'asc'} | Pagination/sort defaults. |
| security | | allow all | { fields, relations, hidden, maxDepth }. |
| keywords | | defaults | Rename reserved query params (see below). |
| provider / jsonPathSyntax | | auto | JSON path syntax; auto-detected from the datasource. |
| cache | | off | true or { max } — cache parsed query plans. |
| onQuery | | — | Telemetry hook (t) => void with per-request timings. |
| basePathPrefix | | '' | Prefix inserted into generated links. |
Renaming reserved parameters
If a column collides with a control parameter, rename the control — globally once, or per endpoint:
import { Keywords } from '@didactika/prisma-autoread';
Keywords.configure({ fields: 'select', filter: 'q' }); // global, once at bootstrap
createAutoRead({ /* … */, keywords: { limit: 'size' } }); // per endpointGET /users?q[age][gte]=30&select=id,firstName&size=20Frameworks
createAutoRead({ … }).applyTo(expressRouter); // Express
createAutoRead({ … }).applyToFastify(fastify); // Fastify
createAutoRead({ … }).applyToHono(honoApp); // HonoFastify and Hono are typed structurally, so neither is a dependency. See docs/frameworks.md.
Security
createAutoRead({
model: 'User',
delegate: prisma.user,
security: {
fields: ['id', 'firstName', 'email'], // only these are filterable/sortable/selectable
relations: ['posts'], // only these can be traversed/included
hidden: ['password', 'resetToken'], // never queryable AND never returned
maxDepth: 5, // reject deeply-nested filters
},
});Anything outside the allow-list returns 400. Omit security (or use '*') to allow
everything. The policy applies to every dialect, the legacy one included.
fields limits what a client may ask for; it does not change what Prisma returns.
For columns that must never leave the server — hashes, tokens, internal flags — use
hidden: those are stripped from every response (including rows reached through
include, include=* or an embedded document) and rejected in filters, sorts,
fields, distinct, group-by and aggregations, worded as if the field did not
exist. Dotted paths reach inside: hidden: ['posts.draftNotes'].
Performance
- DMMF metadata is cached per model as maps → O(1) field/relation lookups.
- Single-pass parsing builds the Prisma
wheredirectly. cache: truememoises parsed query plans by request signature (parsing only — the database is always queried).onQuerygives you{ route, format, method, parseMs, execMs, cacheHit }.listreuses itswherefor the parallelcount.
Error handling
Errors carry a .status; register an error handler after your routes:
app.use((err, _req, res, _next) => res.status(err.status ?? 500).json({ error: err.message }));| Situation | Status |
|---|---|
| Unknown/again-disallowed field, bad operator, malformed RSQL/OData | 400 |
| A backlog capability invoked before it exists | 501 |
An empty result is not an error (200 with data: []).
Migrating from 0.x (legacy)
The original middleware still ships and behaves exactly as before:
import { AutoReadMiddleware, FilterMiddleware } from '@didactika/prisma-autoread';
router.use(FilterMiddleware.processQueryFilters('User'));
AutoReadMiddleware.applyToRouter(router, { modelName: 'User', findByFilter });To adopt the new engine while keeping the old GET query syntax, just declare it
with createAutoRead({ ..., legacy: true }) (the default). When you're ready for the
modern grammar, set legacy: false. QUERY/POST bodies are always Prisma-native.
How it works
HTTP (GET | QUERY | POST)
→ Binding (Express / Fastify / Hono)
→ EndpointController (framework-agnostic pipeline)
→ InputAdapter (query / rsql / odata / json / legacy)
→ QueryBuilder (validate + coerce + map operators, DMMF- and security-aware)
→ QuerySpec (neutral, Prisma-shaped plan)
→ Route (list / count / aggregate / group-by)
→ Executor (findMany / count / aggregate / groupBy)
→ OutputAdapter (hal / plain / jsonapi / csv)Every input protocol produces the same QuerySpec, and every output format consumes
the same result — so adding a protocol, format, route or framework is a self-contained
class plus one registration.
Documentation
Full docs live in docs/:
| Guides | Internals | |---|---| | Getting started · Configuration · Query language | Architecture | | Protocols · Output formats · Routes | UML diagrams — context, containers, domain model, use cases, classes, sequences, state | | Keywords · Security · Performance | | | Frameworks · Migration from 0.x | |
14 runnable examples → — from a five-line endpoint to a complete API, covering every framework, protocol and output format.
Contributors
Thanks to everyone who has contributed to this project:
Want to help? Read CONTRIBUTING.md.
