@generazioneai/genquery
v0.17.0
Published
ORM-agnostic JSON query language with a pluggable Prisma adapter
Readme
genquery
ORM-agnostic JSON query language with a pluggable Prisma adapter.
Frontends send a GenQueryInput object. The backend validates it against a Schema and an adapter translates the result into ORM operations. The shipped adapter targets Prisma.
Install
# npm pubblico (registry.npmjs.org), con provenienza
npm install @generazioneai/genquery
# Prisma client (peer; you almost certainly already have it)
npm install @prisma/clientQuick start (Prisma)
import { Prisma, PrismaClient } from "@prisma/client";
import { createPrismaEngine } from "@generazioneai/genquery/prisma";
const prisma = new PrismaClient();
// 1. One line to set up the schema (from the DMMF), adapter, and engine
const engine = createPrismaEngine(Prisma.dmmf.datamodel);
// 2. Run a query from a request body against a model delegate
const { data, current, total } = await engine.run(
{
searchBy: { firstName: "mario" },
orderBy: "createdAt",
pagination: { page: 0, perPage: 20 },
},
"User", // root entity name — Prisma delegates don't expose it
prisma.user, // target model delegate; entity type flows from this
);
// data: User[]
// current: data.length (omitted if pagination.showNumber === false)
// total: match count (via a parallel count; omitted if pagination.showTotal === false)engine.run is async and returns { data, current?, total? }. current and total are populated according to pagination.showNumber / pagination.showTotal (both default to true); setting showTotal: false skips the extra count.
createPrismaEngine is a thin wrapper around schemaFromPrisma → new PrismaAdapter → new GenQueryEngine. The root entity ("User") must be passed explicitly — Prisma delegates don't expose their model name on a stable public API. The TS entity type is read from the delegate's findMany return.
If you only want the Prisma args object (custom chaining, transactions, your own findMany call), parse separately and call runParsed, which returns the args without executing:
const parsed = engine.parse(input, "User");
const args = engine.runParsed(parsed, prisma.user); // { where, orderBy, skip, take, include, ... }
const rows = await prisma.user.findMany(args);Need fine-grained control? You can still build it manually:
const schema = schemaFromPrisma(Prisma.dmmf.datamodel, { overrides: { User: { meta: "string" } } });
const adapter = new PrismaAdapter(schema, { parallelCount: false });
const engine = new GenQueryEngine({ adapter }); // schema is read from the adapterOr pass the same options to createPrismaEngine:
const engine = createPrismaEngine(Prisma.dmmf.datamodel, {
schema: { overrides: { User: { meta: "string" } } },
adapter: { parallelCount: false },
});Core concepts
Schema
The Schema describes your data model independently of any ORM. The parser uses it to reject unknown fields; the adapter uses it to know which fields are dates vs strings vs relations.
With Prisma, derive it from the generated DMMF — no duplication:
import { schemaFromPrisma } from "@generazioneai/genquery/prisma";
const schema = schemaFromPrisma(Prisma.dmmf.datamodel);
// optional: restrict to specific models
const schema = schemaFromPrisma(Prisma.dmmf.datamodel, { models: ["User", "Post"] });
// optional: override fields with non-standard column types
const schema = schemaFromPrisma(Prisma.dmmf.datamodel, {
overrides: { User: { preferences: "string" } },
});Or declare one explicitly (no ORM, or fine-grained control):
const schema: Schema = {
entities: {
EntityName: {
name: "EntityName",
primaryKey: "id", // optional, defaults to "id"
fields: {
fieldName: { type: "string" | "number" | "boolean" | "date" },
},
relations: {
relationName: { target: "OtherEntity", kind: "one" | "many" },
},
},
},
};Query input
A GenQueryInput is a plain JSON object with five optional top-level keys.
The entity type is inferred automatically from the target argument when it has a recognizable shape (a Prisma model delegate exposes findMany(args?): Promise<T[]>). No explicit generic is required — autocomplete and value-shape checking flow from the delegate's entity type:
// prisma.user is Prisma.UserDelegate — entity type flows into the call below
await engine.run(
{
searchBy: {
firstName: "mario", // OK
age: { operation: ">=", value: 18 }, // OK — number → comparison
birthDate: { after: "2000-01-01T00:00:00Z" }, // OK — date → range
posts: { title: "typescript" }, // OK — relation → recursive
// age: "x", // ✗ type error: number field can't take a string
// nope: "x", // ✗ type error: 'nope' isn't a field on User
},
orderBy: { field: "lastName", order: "asc" }, // ✓ field constrained to User keys
select: { firstName: true }, // ✓ only primitive fields
include: { posts: "all" }, // ✓ only relations
},
"User",
prisma.user,
);The inference distinguishes fields (primitives → searchable / selectable) from relations (objects/arrays → includable / recursive search), and picks the right value shape per field type (string/number/boolean/Date/enum).
If your target type doesn't expose the entity, the input falls back to a loose form where any key/value is accepted — the runtime parser still validates everything against the schema.
Top-level keys:
| Key | Default | Purpose |
|-----|---------|---------|
| searchBy | — | Filter conditions (AND + OR) |
| orderBy | — | Sort field and direction |
| select | "all" | Which fields to return (policy-denied fields are stripped from "all") |
| include | "none" | Which relations to join |
| pagination | "all" | Page / limit |
Full query language reference: docs/query-reference.md
Engine
GenQueryEngine is the public entry point. It reads the schema from the adapter — there is one source of truth.
const engine = new GenQueryEngine({ adapter }); // schema comes from the adapter
// parse + apply + execute → Promise<{ data, current?, total? }>
await engine.run(input, rootEntity, target);
// parse only (requires explicit rootEntity)
const parsed = engine.parse(input, rootEntity);
// apply a previously parsed query without executing (returns the raw args object)
engine.runParsed(parsed, target);Policy (allowlist)
By default every field/relation that exists in the schema is queryable. A policy
restricts that surface per entity, so a frontend can read a field but not filter/sort on it,
not include an expensive relation, or not over-fetch a huge page. Enforced by the parser
(throws QueryValidationError); maxPerPage is clamped, not rejected.
As of 0.14.0, fields denied for select are also stripped from the default selection:
a query without an explicit select (i.e. select: "all") gets a Prisma omit of the
denied fields, at the root and inside included relations — denied/secret fields never
come back, even unasked, while every other column (including ones the genquery schema
doesn't model, like Json or scalar arrays) keeps Prisma's default selection.
Low-level: optional flags on the schema (filterable / sortable / selectable on fields,
includable / filterable on relations, maxPerPage on entities — all default to allowed).
Ergonomic: declare allowlists by name and project them with applyPolicy (ORM-agnostic) or
the policy option of schemaFromPrisma.
import { applyPolicy } from "@generazioneai/genquery";
const restricted = applyPolicy(schema, {
User: {
filterable: ["name", "email"], // only these allowed in searchBy
sortable: ["createdAt"], // only this in orderBy
includable: ["posts"], // only this relation in include
maxPerPage: 100, // perPage > 100 is clamped to 100
},
});
// per axis: array omitted = unrestricted · present = only-listed · empty = none// Prisma: build + restrict in one call
const schema = schemaFromPrisma(Prisma.dmmf.datamodel, {
policy: { User: { filterable: ["name"], includable: ["posts"], maxPerPage: 100 } },
});Errors
Parse failures throw QueryValidationError with a path field pointing to the offending location in the input (e.g. "searchBy.posts.title.value").
import { QueryValidationError } from "@generazioneai/genquery";
try {
await engine.run(input, "User", prisma.user);
} catch (e) {
if (e instanceof QueryValidationError) {
console.error(e.path, e.message);
}
}Examples
See docs/examples.md for full worked examples covering:
- String search modes (splitword, exact —
nativeregexis rejected by the Prisma adapter) - Date ranges
- Numeric comparisons
- OR conditions
- Relation filtering and inclusion
- Pagination and sorting
Limits and secrets (0.15, 0.15.1, 0.16)
Every limit has two modes, GENQUERY_LIMIT_MODE=log|enforce (default log): log measures and logs
(wouldCap / wouldReject) and changes nothing; enforce cuts or rejects. Secrets are the exception: they are
closed in both modes.
| Setting | Default | Effect in enforce |
|---|---|---|
| limits.maxRows / GENQUERY_MAX_ROWS | 2000 | pagination: "all" (and no pagination) gets take: cap+1; the result is cut to cap and carries truncated: true |
| entityMaxRows / EntityDefinition.maxRows / EntityPolicy.maxRows | — | per-entity cap; null = explicit "no cap" exception |
| limits.maxDepth / GENQUERY_MAX_DEPTH | 3 | include or nested relation filter deeper than N hops: QueryValidationError |
| limits.maxFilterDepth / GENQUERY_MAX_FILTER_DEPTH (0.16) | maxDepth | depth of nested relation filters (searchBy) only; violations are filterDepth events (wouldReject in log). Include depth stays on maxDepth |
| limits.toManyAfterToOne | true | no to-many include after a to-one (rule ported from SKILLHR-BE's include-select guard) |
| limits.sharedRoots | [] | root entities with no to-many include at all (global/shared rows) |
| limits.includablePaths / EntityPolicy.includablePaths / buildGenQueryPolicy({ includablePaths }) (0.15.1) | — | per root, the nested include paths allowed (see below) |
| limits.onEvent | console.warn JSON | sink for the events (rowCap, depth, filterDepth, toManyAfterToOne, includablePath, baseIncludeDropped, includeMergeConflict) |
new PrismaAdapter(schema, { limits: { mode: "enforce", maxRows: 2000, sharedRoots: ["AbsenceType"] } });Nested includes: includablePaths (0.15.1)
The structural toManyAfterToOne rule rejects legitimate includes such as
JuridicalIndividual → individual (to-one) → individualDatas (to-many). A root can instead declare the nested paths
it allows; nested includes are then allowed only if declared:
const includablePaths = { JuridicalIndividual: ["individual.individualDatas", "juridical.juridicalDatas"] };
new PrismaAdapter(schema, { limits: { mode: "enforce", includablePaths } });
// or in the policy: buildGenQueryPolicy({ datamodel, includablePaths }) / EntityPolicy.includablePaths- a first-level include (one hop from the root) stays governed by the policy (
includable), as before; - a nested path (
rel.rel[.rel…], relative to the root) is allowed if declared or a prefix of a declared path; - for a root that declares
includablePaths, any other nested path is a violation (includablePathevent:wouldRejectinlog,QueryValidationErrorinenforce), whatever its cardinality; the structural rule no longer applies to that root; - roots that declare nothing keep the structural rule (compatibility);
maxDepthalways applies, andsharedRootsstill forbids any to-many, declared or not;- the adapter option wins over the schema/policy per root; invalid paths (unknown root or relation) make the
PrismaAdapterconstructor throwSchemaError.validateIncludablePaths(schema, paths)returns the problems, for a backend test.
Server-side includes: includeMerge (0.16)
baseArgs.include (trusted, set by the service) used to apply only when the client sent neither include nor
select; otherwise it was silently dropped. PrismaAdapterOptions.includeMerge / GENQUERY_INCLUDE_MERGE:
| Value | Behaviour |
|---|---|
| log (default) | as before 0.16, plus a baseIncludeDropped event (entity, key, reason: clientInclude\|clientSelect) per dropped service key |
| replace | as before 0.16, no event |
| merge | recursive merge, the service is never widened |
merge rules: (1) where the service has a select, or arguments (where, orderBy, take, …) on a key, the service
wins: a client true never widens it nor removes its arguments (a client nested include is added under the
service arguments; a service omit is kept and unioned); (2) client-only keys are added (already validated by parser
and limits); (3) a client select at the root drops the service include as before (event), and select + include
are never emitted at the same level (on a key where they would clash the service include wins, event
includeMergeConflict). mergeIncludes(service, client) is exported and pure.
genquery-check (0.16)
CLI that replays the REAL parseQuery + checkQueryShape (and your limits) on the include / searchBy literals
found in source code, so a limit change or a schema change breaks CI instead of production.
# 1. manifest: entities, relations with cardinality, policy flags, limits
genquery-check manifest --schema prisma/schema.prisma [--limits genquery.limits.js] --out genquery.manifest.json
# 2. TypeScript: literals passed to engine.run/parse, .send(...), rpcCall(...)
genquery-check --lang ts --manifest genquery.manifest.json [--routes routes.json] [--fail-on error|warn|never] src/
# 3. Dart: `include: {…}`, `searchBy:`/`searchParams:` maps and table filter keys `a:b:c`
genquery-check --lang dart --manifest genquery.manifest.json --routes routes.json lib/--schema:schema.prismaor a DMMF JSON ({datamodel}/{models, enums}).--limits: JSON or JS module withmaxDepth,maxFilterDepth,toManyAfterToOne,sharedRoots,includablePaths,policy(EntityPolicyper entity),extraSecretFields,allowSecretFields.--manifestmay be a file or anhttp(s)URL.--routes: JSON{ "<route or cmd>": "<RootEntity>" }(values may be{ "root": … }, the shape the gateway will export from OpenAPIx-genquery-root). Needed to know the root of a Dart literal (linked through the nearestapiUrl:) and of.send({cmd}, …)/rpcCallpayloads;engine.run(q, "Root", …)carries its own root.- Table keys
a:b:care checked as the search{a:{b:{c:…}}}against the roots of--routes(every entity when absent); they pass if any candidate root accepts them. - Output
file:line: error|warn: message. Errors: unknown field or relation, not includable, depth, to-many rule, undeclaredincludablePaths. Warnings: dynamic parts (spread, computed keys, expressions in aninclude), unresolved root. Dynamic values insidesearchByare not warnings (the value type is irrelevant to the shape). --fail-on error|warn|never(defaulterror): exit code 1 on errors only / also on warnings / never (annotations only). Usage error: 2.- The TypeScript mode needs the
typescriptpackage (optional peer dependency). The Dart extractor is a tolerant scanner written in TypeScript (no Dart analyzer): it reads map literals, not Dart semantics.
baseArgs (trusted, server-side) are not checked. DEFAULT_SECRET_FIELDS + DEFAULT_SECRET_PATTERNS
(otp*, secret*, *Encrypted, *Enc) are never selectable, filterable or sortable and applyPolicy cannot
reopen them; schemaFromPrisma takes extraSecretFields and a per-model allowSecretFields exception.
Client for browsers and Next.js: @generazioneai/genquery/client (0.17)
For whoever calls a genquery endpoint. Zero dependencies, no Node APIs, ESM + CJS + types. Nothing from the
engine is pulled in. The wire encoding is the one of the Dart client of skillera_ui
(GenQuery.build + ApiManager.asQueryParams), which is what createGenQueryDecorator({ from: 'query' })
of genquery-nestjs reads back: pagination raw for all/first and JSON otherwise, searchBy, orderBy
({field, direction, order}), include, select as JSON, empty ones omitted.
import { gq, toQueryParam, type GenQueryInput } from "@generazioneai/genquery/client";
const q = gq<Presence>()
.where({ structureId: "s1" })
.or({ attendanceStatus: "PENDING" }, { attendanceStatus: "APPROVED" }) // OR in AND with the rest
.include({ structure: "all", individual: { individualDatas: true } })
.orderBy("createdAt", "desc")
.page(1, 20); // 1-based; on the wire it is {"page":0,"perPage":20}
q.toQueryParam(); // { pagination: '{"page":0,"perPage":20}', searchBy: '{...}', orderBy: '...', include: '...' }
q.toQueryString(); // "pagination=%7B...&searchBy=..." (encodeURIComponent, like Dart)
q.toSearchParams(); // URLSearchParams
q.url("/api/hr/presences"); // "/api/hr/presences?pagination=..."The builder is immutable (every method returns a new one: safe in React state and as a cache key). build() gives the
plain GenQueryInput; toQueryParam(input) encodes one you already have. gq<T>() types where/include/select
against an entity type, gq() stays loose.
Next.js
// app/presenze/page.tsx (Server Component)
import { gq } from "@generazioneai/genquery/client";
export default async function Page({ searchParams }: { searchParams: Promise<{ q?: string; p?: string }> }) {
const { q, p } = await searchParams;
const query = gq<Presence>()
.filters({ "individual:individualDatas:firstName|individual:individualDatas:lastName": q }) // empty → ignored
.orderBy("checkInAt", "desc")
.page(Number(p) || 1, 25);
const res = await fetch(query.url(`${process.env.API_URL}/hr/presences`), { cache: "no-store" });
return <List data={await res.json()} />;
}In a Client Component with SWR/React Query the key is query.toQueryString(); with fetch use query.url(base).
Table filters (filters, searchByFromFilters)
The flat keys of the skillera_ui tables become the nested searchBy: 'a:b:c' nests, 'x|y' is an OR across columns
('a:x|a:y' extracts the common prefix), values are wrapped by key (text → {value, contained: true}, id/…Id…
as-is, …Ids and arrays → {in}, fullName without spaces; null and "" are skipped). buildQueryParams is the
literal port of GenQuery.build/buildFromRaw/findOne (1-based page, pivot, orderByParams with {columnId, mode}).
Known divergence kept on purpose: a Dart DateTimeRange encodes as {gte, lte}, which the engine rejects for dates.
searchByFromFilters reproduces it ({ start, end } → {gte, lte}) so the two clients stay byte-identical; in new code
filter dates with { after, before } through where().
Equivalence with Dart
src/tests/fixtures/dart-equivalence.json holds ~35 cases (taken from skillera_hr and skillera_ops) with the output
produced by the real Dart code; the test compares string by string, then feeds the result to the real parser.
To regenerate after a change to skillera_ui: scripts/dart-equivalence/run.sh <skillera_ui dir> [dart].
(JSON numbers like 1.0 print as 1 in JS and 1.0 in Dart: avoid them in filters.)
Architecture
Three layers, strictly separated:
Wire types (types.ts)
└─ Parser validates + normalizes → Parsed types (parsed.ts)
└─ Adapter consumes ParsedQuery + Schema → ORM outputAdapters never see wire types. New adapters implement Adapter<TTarget, TResult> (re-exported from the package root).
See docs/custom-adapter.md for instructions.
Documentation
| File | Contents | |------|----------| | docs/getting-started.md | Installation, setup, first query | | docs/query-reference.md | Full query language reference | | docs/prisma-adapter.md | Prisma adapter options and internals | | docs/custom-adapter.md | Building a custom adapter | | docs/examples.md | End-to-end examples | | spec.md | Source-of-truth wire format spec |
