# The JSON-native TypeScript ORM for Bun, Deno, Node > UQL is a TypeScript ORM whose queries are plain JSON values, typed to the leaf, with no codegen and one API across every SQL database, MongoDB, and every runtime. Source: https://uql-orm.dev/ ## Every key checked, all the way down A query is a plain object literal: no builder to call, no operator to import, and every key checked to the leaf. All three errors below are compile errors: a misspelled key, the same mistake three levels down inside a populated relation, where the nesting ends only when the query does, and an operator the column’s type rules out. None of them is written into this page; they are what `tsc` reports against the published `uql-orm`. ```ts import { pool } from './uql.config.js'; import { User } from './entities.js'; await pool.findMany(User, { // the entity class types every key $select: { id: true, emial: true }, // same three levels deep $populate: { posts: { $select: { titel: true } } }, // and operators against the column type $where: { loginCount: { $like: 3 } }, }); ``` ```ts import { Entity, Field, Id, ManyToOne, OneToMany } from 'uql-orm'; @Entity() export class Post { @Id({ type: Number }) id?: number; @Field({ type: String }) title?: string | null; @Field({ references: () => User }) authorId?: string | null; @ManyToOne({ entity: () => User, references: (post) => post.authorId }) author?: User; } @Entity() export class User { @Id({ type: 'uuid' }) id?: string; @Field({ type: String }) email?: string | null; @Field({ type: Number }) loginCount?: number | null; @OneToMany({ entity: () => Post, mappedBy: (post) => post.author }) posts?: Post[]; } ``` ```ts import { PgQuerierPool } from 'uql-orm/postgres'; import { User } from './entities.js'; export const pool = new PgQuerierPool({ connectionString: process.env.DATABASE_URL, }); export default { pool, entities: [User] }; ``` None of this needs a code generation step. Entities are plain classes using the standard TC39 decorators, so there is no schema file to keep in sync, no client to regenerate, and no compiler flag to enable. The same checking reaches into [JSON/JSONB](https://uql-orm.dev/querying/json.md) dot-paths, down to a key inside a stored document. See [Entities](https://uql-orm.dev/entities/basic.md). --- ## Same query can travel between browser, edge, and backends Queries are serializable, so the browser can build and send one with the same type-safety. Four files, one call each: the browser call, the entity, the pool, and the endpoint that exposes it: Browser: ```ts import { HttpQuerier } from 'uql-orm/browser'; const http = new HttpQuerier('/api'); const { data } = await http.findMany(User, { $select: { id: true, email: true }, $where: { email: { $endsWith: '@uql-orm.dev' } }, }); ``` entities.ts: ```ts import { v7 as uuidv7 } from 'uuid'; import { Entity, Id, Field } from 'uql-orm'; @Entity() export class User { @Id({ type: 'uuid', onInsert: uuidv7 }) id?: string; @Field({ type: String, unique: true }) email?: string | null; } ``` uql.config.ts: ```ts import { PgQuerierPool } from 'uql-orm/postgres'; export const pool = new PgQuerierPool({ connectionString: process.env.DATABASE_URL, }); ``` Server: ```ts import { createFetchHandler } from 'uql-orm/http'; export const handler = createFetchHandler({ pool, include: [User] }); ``` That handler mounts on Hono, Elysia, Next.js, Express, Bun, Deno, or Workers, and carries transactions and authorization hooks across. More on the [HTTP transport](https://uql-orm.dev/http.md) and the [browser client](https://uql-orm.dev/browser.md). --- ## The rest, briefly One package, zero runtime dependencies and every dialect included, yet `uql-orm/postgres` is about 27 kB gzipped. The same code runs on PostgreSQL, PGlite, CockroachDB, MySQL, MariaDB, MSSQL, SQLite, Turso, libSQL, Neon, Cloudflare D1, Bun’s native SQL and MongoDB, under Node 24+, Bun, Deno, [Workers](https://uql-orm.dev/cloudflare-d1.md), [Lambda and Vercel](https://uql-orm.dev/serverless.md), and [the browser](https://uql-orm.dev/browser.md). It is ESM only, which rules out CommonJS projects. [Relations](https://uql-orm.dev/querying/relations.md) never hit N+1: the whole graph is one statement, however many rows come back, loaded eagerly so serializing the result touches nothing. [Migrations](https://uql-orm.dev/migrations.md) are generated from your entities and reviewed as SQL in the pull request, with `drift:check` to catch a database that stopped matching. [`raw()`](https://uql-orm.dev/querying/raw-sql.md) fits anywhere a value does when you want the SQL yourself. [Semantic and vector search](https://uql-orm.dev/ai-semantic-search.md), [tenant filters that fail closed](https://uql-orm.dev/multi-tenancy.md), [soft delete with restore](https://uql-orm.dev/entities/soft-delete.md) and [streaming](https://uql-orm.dev/querying/streaming.md) are all included. On a full PostgreSQL round trip UQL adds less over hand-written driver code than any other ORM we measured, on Bun, Node and Deno alike. Full [benchmark](https://uql-orm.dev/benchmark.md), [feature-by-feature comparison](https://uql-orm.dev/comparison.md), as well as [type-safety comparison](https://uql-orm.dev/type-safety.md). # Quick Start > Install UQL, define an entity, and run your first query. Source: https://uql-orm.dev/getting-started **[UQL](https://uql-orm.dev/index.md)** is type-safe to the leaf with nothing to generate: entities are plain classes, and every query is plain JSON that runs unchanged on the server, in the browser, or over the network. ## 1. Install Install the core and your preferred driver: npm: ```sh npm install uql-orm pg # or mysql2, better-sqlite3, mongodb, etc. ``` bun: ```sh bun add uql-orm # bun:sql and bun:sqlite need no driver ``` pnpm: ```sh pnpm add uql-orm pg # or mysql2, better-sqlite3, mongodb, etc. ``` ### Requirements Node 24+, Bun, Deno, or an edge runtime like Cloudflare Workers, plus TypeScript 5.2 or newer. UQL is ESM only: there is no `require()` path, so a CommonJS project cannot consume it. ```json title="tsconfig.json" { "compilerOptions": { // `nodenext` or `preserve`: the resolver has to read `exports` to find `uql-orm/postgres` "module": "nodenext", // any dated target works (es2022+), never `esnext`: it leaves decorator syntax untransformed "target": "es2025", // `await using` needs `AsyncDisposable`, which no dated target's default lib carries "lib": ["esnext"] } } ``` Behind a bundler, `"module": "preserve"` (TypeScript 5.4+) or `"moduleResolution": "bundler"` does the same job. [Next.js](https://uql-orm.dev/nextjs.md) needs no changes at all; Vite’s template pins `lib` to `ES2022`, so add `esnext` there. Node also needs `"type": "module"` in `package.json` to run the compiled output, which Bun, Deno and bundler-driven frameworks do not. --- ## 2. Complete Example An entity, a pool, and a query: ```ts title="entities.ts" import { v7 as uuidv7 } from 'uuid'; import { Entity, Id, Field } from 'uql-orm'; @Entity() export class User { @Id({ type: 'uuid', onInsert: uuidv7 }) id?: string; @Field({ type: String, unique: true }) email?: string; @Field({ type: String }) name?: string; } // uql.config.ts import type { Config } from 'uql-orm'; import { PgQuerierPool } from 'uql-orm/postgres'; import { User } from './entities.js'; const pool = new PgQuerierPool({ host: 'localhost', user: 'postgres', password: 'password', database: 'uql_app', }); export default { pool, entities: [User] } satisfies Config; export { pool }; // app.ts import { pool } from './uql.config.js'; import { User } from './entities.js'; // A single operation goes straight on the pool: it acquires a connection, runs, and releases it. await pool.insertMany(User, [ { email: 'ada@uql-orm.dev', name: 'Ada' }, { email: 'alan@uql-orm.dev', name: 'Alan' }, { email: 'grace@example.com', name: 'Grace' }, ]); // Same for reads. const users = await pool.findMany(User, { $select: { id: true, name: true }, $where: { email: { $endsWith: '@uql-orm.dev' } }, $limit: 10, }); console.log(users); // -> Ada and Alan; Grace's email doesn't match ``` The `User` table has to exist before that insert runs; [step 3](#3-create-the-tables) generates it from the entity class. Build the pool once per process and import it everywhere; nothing connects until the first query. Which driver, how big, and when to close it are on [Pool](https://uql-orm.dev/pool.md). Every operation lives on both the pool and the querier. A pool call is one unit of work on its own connection; `pool.withQuerier` (or [`pool.transaction`](https://uql-orm.dev/querying/transactions.md) when it must be all-or-nothing) pins one connection across several. See [pool vs. querier](https://uql-orm.dev/querying/querier.md#choosing-poolx-vs-querierx). --- ## 3. Create the tables UQL generates tables from entities, and entities from tables. On an empty database, one command creates the table: ```sh npx uql-migrate sync ``` That reads the `entities` in your `uql.config.ts`, diffs them against the database, and creates what is missing. Add `--dry-run` to read the DDL before it runs. Keep `sync` for development; once the data matters, [`generate:entities`](https://uql-orm.dev/migrations.md#from-entities-to-the-database) writes the same diff to a file you review in the pull request and can roll back. Starting from tables that already exist runs the other direction: [`generate:from-db`](https://uql-orm.dev/migrations.md#from-a-database-to-entities) writes the entity classes for you. --- ## Next Steps - [Define Entities](https://uql-orm.dev/entities/basic.md): Explore all decorators and type abstractions. - [Define Relations](https://uql-orm.dev/entities/relations.md): One-to-one, one-to-many, and many-to-many mappings. - [Querying](https://uql-orm.dev/querying/querier.md): Deep selection, filtering, and sorting. - [Transactions](https://uql-orm.dev/querying/transactions.md): Automatic and manual transaction patterns. - [Migrations](https://uql-orm.dev/migrations.md): Schema evolution with the CLI and Drift Detection. # Switching to UQL > Scaffold entities from the database you already run, translate the queries you already write, and move a production system over in phases. Source: https://uql-orm.dev/switching-to-uql UQL runs in the same process as Drizzle, MikroORM, Mongoose, Prisma or TypeORM, so you can move one endpoint at a time instead of all at once. It holds its own pool, keeps no identity map or session, and passes plain objects both ways. Four moves, in this order: [scaffold entities](#step-1-scaffold-entities-from-the-database-you-have) from the live database, [run UQL beside what you have](#step-2-run-it-beside-your-current-orm), [translate the queries](#translating-what-you-already-write) you write today, then [move traffic in phases](#migrating-in-phases). The [habits that translate badly](#habits-to-unlearn) are at the end. ## Step 1: Scaffold entities from the database you have You do not hand-write entities for tables that already exist. Point the CLI at the live database and it writes the `@Entity` classes, including relations inferred from the foreign keys it finds: ```bash npx uql-migrate generate:from-db --output ./src/entities ``` That needs a config with a pool. If your columns are `snake_case` and your code is `camelCase`, set the [naming strategy](https://uql-orm.dev/naming-strategy.md) here and the translation applies to both queries and generated DDL: ```ts title="uql.config.ts" import { SnakeCaseNamingStrategy, type Config } from 'uql-orm'; import { PgQuerierPool } from 'uql-orm/postgres'; const pool = new PgQuerierPool( { connectionString: process.env.DATABASE_URL }, { namingStrategy: new SnakeCaseNamingStrategy() }, ); export default { pool, migrationsPath: './migrations' } satisfies Config; export { pool }; ``` Then check the scaffold against reality before trusting it: ```bash npx uql-migrate drift:check ``` Drift check compares the entities to the running database and reports missing tables and columns, type mismatches, and unexpected columns. Keep it in CI: it is the same command that later tells you an entity and a legacy migration have diverged. One blind spot: relations come off foreign key constraints, so a junction table that declares none arrives as a plain entity. Indexes come back whole, access method, partial predicates and `INCLUDE` columns included. See [scaffolding](https://uql-orm.dev/migrations.md#from-a-database-to-entities) for what comes back, [drift detection](https://uql-orm.dev/migrations.md#drift-detection) for what it compares. This is the one time the database defines the entities. From here the arrow reverses: edit a class and [`generate:entities`](https://uql-orm.dev/migrations.md#from-entities-to-the-database) writes the migration that brings the database to match. ## Step 2: Run it beside your current ORM Nothing is shared between the two: no cache to invalidate, no session to keep consistent, no entity that can be attached to the wrong context. The cost is a second connection pool: lower each one’s max so the pair still fits inside the database’s connection limit. The connection model itself is the one you already use. In Prisma, Sequelize, and TypeORM a plain query takes its own pooled connection, so `Promise.all` parallelizes. [UQL’s pool](https://uql-orm.dev/querying/querier.md#choosing-poolx-vs-querierx) does the same for reads and writes alike: `pool.findMany(User, {...})` and `pool.insertOne(User, {...})` each acquire, run, and release. Reach for `pool.withQuerier` or `pool.transaction` when several statements must share one connection or commit atomically. What you cannot do is span a transaction across both ORMs. A unit of work that writes through the legacy ORM and through UQL runs as two transactions on two connections, so migrate at the boundary of a whole unit of work rather than splitting one in half. ## The shift in mental model Each source ORM has one idea you have to put down. The rest is vocabulary. ### From Prisma: no schema file, no codegen Prisma keeps the schema in a `.prisma` file and a `generate` step turns it into a client, so your model and your code are two artifacts that can disagree, and CI grows a build step. - **Prisma:** edit `.prisma`, run `npx prisma generate`, then use the generated client. - **UQL:** edit the `@Entity` class, then query it. The TypeScript class *is* the schema, and the [standard decorators](https://uql-orm.dev/entities/basic.md) are checked against the properties they annotate. ### From Drizzle: a declarative object instead of composed SQL Drizzle builds SQL out of functions, so every condition is an import and a complex query accumulates `eq()`, `and()`, and `sql` templates. UQL queries are plain JSON, which is also why they survive a trip over the network. - **Drizzle:** `db.select().from(users).where(and(eq(users.id, 1), gte(users.age, 18)))` - **UQL:** `pool.findMany(User, { $where: { id: 1, age: { $gte: 18 } } })` ### From MikroORM or TypeORM: explicit mutations instead of managed state A Unit of Work and Identity Map track loaded entities and flush changes for you. That buys convenience and costs you detached-entity errors and writes you did not ask for. UQL never tracks an object, so a mutation happens only where you call one. - **Managed:** `user.name = 'New Name'; await em.flush();` - **UQL:** `await pool.updateOneById(User, id, { name: 'New Name' });` ### From Mongoose: keep the query style, gain SQL Mongoose filters are objects of operators (`$gte`, `$in`, `$regex`, `$elemMatch`, `$or`), and so are [UQL’s](https://uql-orm.dev/querying/comparison-operators.md). The vocabulary carries over almost intact. Only the target changes: a document becomes an `@Entity` class mapped to a table and its relations. Because the same query runs on MongoDB *and* every supported SQL engine, you can move off Mongo table by table instead of rewriting the data layer in one release. - **Mongoose:** `User.find({ status: 'active' }).sort('-createdAt').limit(10)` - **UQL:** `pool.findMany(User, { $where: { status: 'active' }, $sort: { createdAt: 'desc' }, $limit: 10 })` ## Translating what you already write ### Method and operator equivalents Pick the ORM you are coming from. Every method below is on the pool and on a [querier](https://uql-orm.dev/querying/querier.md), with the same name and arguments. Prisma: | Prisma | UQL | | - | - | | `findMany({ where, select, orderBy, take, skip })` | `findMany(User, { $where, $select, $sort, $limit, $skip })` | | `findFirst({ where })` | `findOne(User, { $where })` | | `findUnique({ where: { id } })` | `findOneById(User, id)` | | `count({ where })` | `count(User, { $where })` | | `groupBy` / `aggregate` | [`aggregate(User, { $group, $select, $having })`](https://uql-orm.dev/querying/aggregate.md) | | `create({ data })` | `insertOne(User, data)` - returns the id, not the row | | `createMany({ data })` | `insertMany(User, data)` - returns an id per row, on every database | | `update({ where: { id }, data })` | `updateOneById(User, id, data)` | | `updateMany({ where, data })` | `updateMany(User, { $where }, data)` | | `upsert({ where, create, update })` | `upsertOne(User, conflictPaths, data)` | | `delete` / `deleteMany` | `deleteOneById` / `deleteMany` | | `include` / nested `select` | [`$populate`](https://uql-orm.dev/querying/relations.md) | | `$transaction(fn)` | [`pool.transaction(fn)`](https://uql-orm.dev/querying/transactions.md) | | `$queryRaw` / `$executeRaw` | [`all(sql, values)`](https://uql-orm.dev/querying/raw-sql.md) / `run(sql, values)` | | `{ contains: 'x' }` | `{ $includes: 'x' }`, or `$iincludes` for `mode: 'insensitive'` | | `{ startsWith: 'x' }` | `{ $startsWith: 'x' }` / `{ $istartsWith: 'x' }` | | `{ notIn: [...] }` | `{ $nin: [...] }` | | `{ field: null }` | `{ $isNull: true }` | | `AND` / `OR` / `NOT` | [`$and` / `$or` / `$not`](https://uql-orm.dev/querying/logical-operators.md) | Drizzle: | Drizzle | UQL | | - | - | | `db.select().from(users).where(...)` | `findMany(User, { $where })` | | `db.select({ id: users.id })` | `$select: { id: true }` | | `db.query.users.findMany({ with: { posts: true } })` | `$populate: { posts: true }` | | `.orderBy(desc(users.createdAt))` | `$sort: { createdAt: 'desc' }` | | `.limit(n)` / `.offset(n)` | `$limit: n` / `$skip: n` | | `db.$count(users, ...)` | `count(User, { $where })` | | `.groupBy().having()` | [`aggregate(User, { $group, $select, $having })`](https://uql-orm.dev/querying/aggregate.md) | | `db.insert(users).values(v).returning()` | `insertOne(User, v)` / `insertMany(User, [v])` | | `db.update(users).set(v).where(...)` | `updateMany(User, { $where }, v)` | | `db.delete(users).where(...)` | `deleteMany(User, { $where })` | | `.onConflictDoUpdate({ target, set })` | `upsertOne(User, conflictPaths, data)` | | `db.transaction(fn)` | [`pool.transaction(fn)`](https://uql-orm.dev/querying/transactions.md) | | ``db.execute(sql`...`)`` | [`all(sql, values)`](https://uql-orm.dev/querying/raw-sql.md) / `run(sql, values)` | | `and(...)` / `or(...)` / `not(...)` | [`$and` / `$or` / `$not`](https://uql-orm.dev/querying/logical-operators.md); `$and` is implicit between keys | | `gte(users.age, 18)` | `{ age: { $gte: 18 } }` | | `like` / `ilike` | `{ $like }` / `{ $ilike }`, or `$includes` / `$iincludes` to skip the wildcards | | `inArray` / `notInArray` | `{ $in }` / `{ $nin }` | | `isNull` / `isNotNull` | `{ $isNull: true }` / `{ $isNotNull: true }` | TypeORM: | TypeORM | UQL | | - | - | | `find(User, { where, select, order, take, skip })` | `findMany(User, { $where, $select, $sort, $limit, $skip })` | | `findOne` / `findOneBy` | `findOne(User, { $where })` | | `findOneBy({ id })` | `findOneById(User, id)` | | `count` / `countBy` | `count(User, { $where })` | | `relations: ['posts']`, `leftJoinAndSelect` | [`$populate`](https://uql-orm.dev/querying/relations.md), with `$required: true` for an inner join | | `createQueryBuilder().groupBy().having()` | [`aggregate(User, { $group, $select, $having })`](https://uql-orm.dev/querying/aggregate.md) | | `insert(User, data)` | `insertOne(User, data)` / `insertMany(User, data)` | | `save(entity)` | `saveOne(User, data)` - inserts, or upserts on the key when the payload names it | | `update(User, id, data)` | `updateOneById(User, id, data)` | | `delete(User, id)` | `deleteOneById(User, id, { hardDelete: true })` | | `softDelete` / `restore` | `deleteOneById` / [`restoreOneById`](https://uql-orm.dev/entities/soft-delete.md) | | `manager.transaction(fn)` | [`pool.transaction(fn)`](https://uql-orm.dev/querying/transactions.md) | | `manager.query(sql)` | [`all(sql, values)`](https://uql-orm.dev/querying/raw-sql.md) / `run(sql, values)` | | `MoreThanOrEqual(18)` | `{ $gte: 18 }` | | `Between(a, b)` | `{ $between: [a, b] }` | | `Like('%x%')` / `ILike('%x%')` | `{ $includes: 'x' }` / `{ $iincludes: 'x' }` | | `In([...])` / `Not(In([...]))` | `{ $in: [...] }` / `{ $nin: [...] }` | | `IsNull()` | `{ $isNull: true }` | MikroORM: | MikroORM | UQL | | - | - | | `em.find(User, where, { fields, orderBy, limit, offset })` | `findMany(User, { $where, $select, $sort, $limit, $skip })` | | `em.findOne(User, where)` | `findOne(User, { $where })` / `findOneById(User, id)` | | `em.count(User, where)` | `count(User, { $where })` | | `populate` + `populateFilter` | [`$populate`](https://uql-orm.dev/querying/relations.md) with a `$where` per relation | | `qb.groupBy().having()` | [`aggregate(User, { $group, $select, $having })`](https://uql-orm.dev/querying/aggregate.md) | | `em.create(...)` + `em.flush()` | `insertOne(User, data)` - there is no flush | | `em.nativeUpdate(User, where, data)` | `updateMany(User, { $where }, data)` / `updateOneById` | | `em.nativeDelete(User, where)` | `deleteMany(User, { $where })` / `deleteOneById` | | `em.upsert` / `em.upsertMany` | `upsertOne` / `upsertMany` | | `em.transactional(fn)` | [`pool.transaction(fn)`](https://uql-orm.dev/querying/transactions.md) | | `em.getConnection().execute(sql)` | [`all(sql, values)`](https://uql-orm.dev/querying/raw-sql.md) / `run(sql, values)` | | `$gte`, `$nin`, `$like`, `$or`, `$elemMatch` | same names, and typed per field, so `{ age: { $like } }` fails to compile | | `$ilike` (PostgreSQL only) | `$ilike`, `$istartsWith`, `$iincludes` on every dialect | | `filters: { softDelete: ... }` | [`@Field({ softDelete: true })`](https://uql-orm.dev/entities/soft-delete.md), plus general [query filters](https://uql-orm.dev/querying/filters.md) | Mongoose: | Mongoose | UQL | | - | - | | `User.find(filter).sort().limit().skip()` | `findMany(User, { $where, $sort, $limit, $skip })` | | `User.findOne(filter)` | `findOne(User, { $where })` | | `User.findById(id)` | `findOneById(User, id)` | | `User.countDocuments(filter)` | `count(User, { $where })` | | `.select('id name')` | `$select: { id: true, name: true }` | | `.populate('posts')` | [`$populate: { posts: true }`](https://uql-orm.dev/querying/relations.md) - a join, not a second round trip | | `User.aggregate([...])` | [`aggregate(User, { $group, $select, $having })`](https://uql-orm.dev/querying/aggregate.md) | | `User.create(doc)` / `insertMany` | `insertOne(User, doc)` / `insertMany(User, docs)` | | `findByIdAndUpdate(id, doc)` | `updateOneById(User, id, doc)` | | `updateMany(filter, { $set: doc })` | `updateMany(User, { $where }, doc)` | | `deleteOne` / `deleteMany` | `deleteOneById` / `deleteMany` | | `session.withTransaction(fn)` | [`pool.transaction(fn)`](https://uql-orm.dev/querying/transactions.md) | | `{ $regex: 'x' }` | `{ $iincludes: 'x' }` for a plain substring, `$regex` when you need the pattern | | `{ $gte }`, `{ $in }`, `{ $nin }`, `$or`, `$elemMatch`, `$size` | same names | | Subdocuments and arrays of objects | a [JSON column](https://uql-orm.dev/querying/json.md) for schemaless blobs, a relation for anything you filter or join | ### Patterns worth seeing side by side Where the translation is more than renaming keys. The [comparison page](https://uql-orm.dev/comparison.md) sets every common operation side by side, with the trade-offs. #### Filtering, sorting, paging Prisma: ```ts prisma.user.findMany({ where: { age: { gte: 18 }, status: 'active', email: { contains: '@uql-orm.dev' } }, orderBy: { createdAt: 'desc' }, take: 10 }); ``` Drizzle: ```ts import { and, gte, eq, like, desc } from 'drizzle-orm'; db.select() .from(users) .where(and( gte(users.age, 18), eq(users.status, 'active'), like(users.email, '%@uql-orm.dev%') )) .orderBy(desc(users.createdAt)) .limit(10); ``` TypeORM: ```ts import { MoreThanOrEqual, Like } from 'typeorm'; manager.find(User, { where: { age: MoreThanOrEqual(18), status: 'active', email: Like('%@uql-orm.dev%') }, order: { createdAt: 'DESC' }, take: 10 }); ``` MikroORM: ```ts em.find(User, { age: { $gte: 18 }, status: 'active', email: { $like: '%@uql-orm.dev%' } }, { orderBy: { createdAt: 'DESC' }, limit: 10 }); ``` Mongoose: ```ts User.find({ age: { $gte: 18 }, status: 'active', email: { $regex: '@uql-orm.dev' } }) .sort({ createdAt: -1 }) .limit(10); ``` UQL: ```ts pool.findMany(User, { $where: { age: { $gte: 18 }, status: 'active', email: { $includes: '@uql-orm.dev' } }, $sort: { createdAt: 'desc' }, $limit: 10 }); ``` #### Atomic JSON updates Changing one key of a JSON column without reading and rewriting the whole object: Other ORMs (raw SQL): ```ts await db.execute( `UPDATE users SET settings = jsonb_set(settings, '{theme}', '"dark"') WHERE id = 1` ); ``` UQL: ```ts await pool.updateOneById(User, 1, { settings: { $set: { theme: 'dark' } } }); ``` Read-modify-write loses concurrent writes to other keys of the same document. `$set`, `$unset`, `$push`, and `$pull` compile to each dialect’s own JSON functions; see [JSON / JSONB](https://uql-orm.dev/querying/json.md). Two more move further than a rename. Aggregation takes grouped columns out of the select list into `$group` and computed ones into named `$select` entries, and `$having` and `$sort` are then checked against those names ([aggregate queries](https://uql-orm.dev/querying/aggregate.md)). Semantic search is a `$sort` with `$vector`: one typed query on every engine with vectors, where the others drop to raw SQL, a PostgreSQL-only helper or an Atlas-only pipeline ([AI & RAG](https://uql-orm.dev/ai-semantic-search.md)). ## Migrating in phases A one-release rewrite gives you no way back. These four phases each leave the legacy path intact until the new one has proven itself. ### Phase 1: reads, on one endpoint Reimplement a single non-critical read with UQL and leave the old one running. Shadow it: run both, compare the result sets, log the differences. Plain objects in and out mean UQL cannot corrupt the other ORM’s cache or state while you do this, so the worst case is a bad response on one endpoint. ### Phase 2: new tables and features Build everything new on UQL, with [`uql-migrate generate:entities`](https://uql-orm.dev/migrations.md#from-entities-to-the-database) creating its tables from the entity classes. This exercises the whole loop (entity, migration, query, deploy) on data no existing code depends on. ### Phase 3: writes, one entity at a time Move `INSERT`, `UPDATE`, and `DELETE` per entity rather than per endpoint, so a given table has exactly one writer at a time. Keep the legacy write path in the codebase until that entity is verified in production. Where the legacy ORM had lifecycle callbacks, cascades, or validation hooks on the entity, port them to [lifecycle hooks](https://uql-orm.dev/entities/lifecycle-hooks.md) in the same change: they are the easiest thing to leave behind, and their absence is silent. ### Phase 4: cutover When no query runs through the legacy ORM, remove it from `package.json` along with its `.prisma` file, Drizzle snapshots, or data source config. Keep the old migration history table if it records applied SQL you may still need to audit. Your `@Entity` classes are then the only schema definition left, and `drift:check` in CI is what keeps them honest. ## Habits to unlearn The [shift in mental model](#the-shift-in-mental-model) covers the big ones: no flush, no schema file, no generate step. One more: - **`require()`.** UQL is ESM only, with no `require()` path to fall back to. Move to `import`, and add `"type": "module"` to `package.json` if Node runs your compiled output. See [Requirements](https://uql-orm.dev/getting-started.md#requirements) for the three `tsconfig.json` settings that go with it. # Upgrade guide > What each release asks of you, newest first, with the version and date it landed. Source: https://uql-orm.dev/upgrade-guide Only releases that *may* require something on your side. Everything else, and what a custom dialect or driver sees, is in the [changelog](https://github.com/rogerpadilla/uql/blob/main/CHANGELOG.md). ## 0.77.1 - 2026-09-20 Rename `UqlLockUsageError` to `UqlUsageError`, which every misuse now throws: the old name still resolves to it, so an `instanceof` keeps working. A call the API cannot carry out answers `400` over HTTP where it answered `500`. ## 0.73.0 - 2026-09-18 Rename `WithDistance` and `WithScore` to `WithProjection`, which types the row any `$sort` `$project` names. On SQLite, libSQL and Turso, a vector column created before 0.71.0 is `TEXT`, which `drift:check` now reports: recreate its table in a migration with the column as `F32_BLOB`, which libSQL’s vector index needs to build. ## 0.70.0 - 2026-09-18 Run the [codemod](https://uql-orm.dev/codemod.md): a nullable column’s property admits `null`, `name?: string | null`, so it appends the `| null`. Give a column that never holds one `nullable: false` instead. An `updateMany` or `deleteMany` naming no rows throws; pass `{ unfiltered: true }` where you mean the whole table. A write payload naming a `readonly` field no longer compiles, since its value was dropped. Replace `$sumDistinct` and `$avgDistinct` with a `$group` on the field. ## 0.68.1 - 2026-09-17 An RPC contract takes [`WireQuery`](https://uql-orm.dev/trpc.md), a `raw` or a `Uint8Array` in a browser-client query or payload no longer compiles, and a cast past the types throws rather than corrupting the row: keep both server-side. Run the [codemod](https://uql-orm.dev/codemod.md) for `D1Database`, now `D1Queryable`. A query type names its `raw` before its key set, `QueryWhere`. ## 0.68.0 - 2026-09-17 On PostgreSQL and CockroachDB, [generate a migration](https://uql-orm.dev/migrations.md) for a numeric `jsonPath` index: its expression changed, and the planner no longer matches the old one. An `$elemMatch` on one `$eq` or `$in` compares by JSON type, so `'5'` stops matching `5`, and an object in `$all` matches an element holding its keys rather than only an identical one. ## 0.66.0 - 2026-09-16 Run the [codemod](https://uql-orm.dev/codemod.md): every to-one names its foreign key in `references`, and declares the column where a relation used to create it. For a to-one onto a composite key, declare a column per key and pair them. Then `tsc` points at what registration used to refuse at startup: a security filter that skips, and a `mappedBy` or `references` naming a column that cannot hold the key it joins. ## 0.65.0 - 2026-09-14 Run the [codemod](https://uql-orm.dev/codemod.md), then give each junction column `references`, declare a relation for any `@Field({ references })` column you `$populate`, and read `pool.dialect.dialectName` instead of `migrator.dialectName`. ## 0.63.0 - 2026-09-13 Write an index expression as `raw` in the list, ``(user) => [raw`lower(${user.email})`]``, not a callback per expression. Move SQLite off `uql-orm/bunSql` to [`Sqlite3QuerierPool`](https://uql-orm.dev/sqlite.md), upgrade `@tursodatabase/serverless` to 1.3+, and pass a libSQL client you built to [`LibsqlQuerierPool`](https://uql-orm.dev/turso.md#libsql). SQLite now reads an integer past 2^53 as text. ## 0.62.0 - 2026-09-13 Run the [codemod](https://uql-orm.dev/codemod.md): it rewrites the renamed options and `col()`, now [`refs(Entity)`](https://uql-orm.dev/querying/raw-sql.md). SQL an entity declares reads its columns off a callback’s refs, ``(user) => raw`lower(${user.email})` ``. ## 0.60.0 - 2026-09-12 A [builder migration](https://uql-orm.dev/migrations/builder.md) receives the builder, with the querier second: `up(m, querier)`. One written to call `querier.run` there now reaches the builder instead; move it to `defineMigration`. ## 0.58.0 - 2026-09-11 A member is never named by a string, so a rename in your editor reaches it. Definitions read it off a key map: `@Index((post) => [post.title])`, `mappedBy: (post) => post.author`, `references`. Statements name it by a key: `aggregate()` takes its computed columns in `$select` instead of `$agg`, and `$text` takes `$fields: { title: true }`. The [codemod](https://uql-orm.dev/codemod.md) rewrites all of it. ```ts await pool.aggregate(Order, { $group: { status: true }, $select: { total: { $sum: { amount: true } } }, }); ``` ## 0.57.0 - 2026-09-11 A to-many `$populate` and `$count` are read in the parent’s statement, which needs MySQL 8.0.14+ and SQLite 3.44+. Each table in a statement reads under its own name, relation key or join path, so a `raw()` naming a related table has to use that alias. Every foreign key gets an index unless one already leads with it (`index: false` opts a column out), so the next migration adds them. ## 0.55.0 - 2026-09-10 A BIGINT past 2^53 reads back as its exact text, where most drivers rounded it; declare `type: BigInt` for a typed exact integer. The [codemod](https://uql-orm.dev/codemod.md) renames `CrdbQuerier`/`NeonQuerier` to `PgQuerier` and `LibsqlQuerier`/`TursoQuerier` to `HranaQuerier`. Raw access on Bun SQL is `pool.sql`. ## 0.54.0 - 2026-09-10 `virtual` and `raw('sql')` are gone; the [codemod](https://uql-orm.dev/codemod.md) rewrites both. An `after*` hook’s `this` is now a copy of the row as written, so mutating it leaves the object you passed alone. ## 0.53.0 - 2026-09-10 `$where` takes a map and nothing else, so TypeScript reports a wrong value on the key it sits on. An id, a list of ids or a bare `raw()` in its place stops compiling, and over HTTP answers 400. Name the key, and put a raw expression in `$and`: ```ts import { raw } from 'uql-orm'; await pool.findMany(User, { $where: { id: [1, 2] } }); await pool.findMany(User, { $where: { $and: [raw`"createdAt" > now()`] } }); ``` The [codemod](https://uql-orm.dev/codemod.md) renames `QueryWhereMap` to `QueryWhere` and reports the rest. ## 0.52.0 - 2026-09-09 The pool builds the engine’s own dialect, so `BunSqlPostgresDialect`, `BunSqlCockroachDialect` and `BunSqliteDialect` are gone. ## 0.48.0 to 0.51.0 - 2026-09-09 Four releases in one day, all about how a write names its rows. `saveOne`/`saveMany` upsert on the key a row names, instead of guessing from whether an id is present. A stale id writes the row now rather than updating nothing, and [composite keys work](https://uql-orm.dev/querying/methods.md#saveone--savemany). A row that names its key fires `@BeforeUpsert`/`@AfterUpsert`, so move whatever a `@BeforeUpdate` was doing on a save. Every write reports its id in one shape: the column’s value on a single key, the [key map](https://uql-orm.dev/querying/methods.md#composite-keys) on a composite, where a composite insert used to report `undefined`. `firstId` is gone from `upsertOne` and `upsertMany`, replaced by `id` and by `ids` in payload order. A key not called `id`, `_id` or `uuid`, and a composite whatever its columns are called, has to be named by the `idKey` brand, which the [codemod](https://uql-orm.dev/codemod.md) writes. `@Id` refuses one without it: ```ts import { Entity, Id, idKey } from 'uql-orm'; @Entity() export class Enrolment { [idKey]?: 'studentId' | 'courseId'; @Id({ type: Number }) studentId?: number; @Id({ type: String }) courseId?: string; } ``` On MongoDB, a key you supply is the document’s `_id`, where it used to land beside the one the driver minted so `findOneById` never found the row; documents written before this keep the minted id. Ids read back as hex strings, and a key left to the database has to be one [MongoDB can mint](https://uql-orm.dev/mongodb.md#the-key-has-to-be-one-mongodb-can-produce). ## 0.46.0 - 2026-09-08 `virtual` is renamed to [`computed`](https://uql-orm.dev/entities/computed-fields.md). Both work for one release, giving both throws, and the [codemod](https://uql-orm.dev/codemod.md) rewrites it. ## 0.45.0 - 2026-09-08 A sync now applies foreign keys, so read a `planSync()` before the first one; not on SQLite, whose only way to change a constraint is rebuilding the table. A generated key is spelled from the type it declares, so on MySQL and MariaDB it is no longer `UNSIGNED`, and a key left `UNSIGNED` refuses every constraint pointing at it: ```sql title="MySQL" -- on a database created before this, under safe: false ALTER TABLE `Company` MODIFY COLUMN `id` BIGINT AUTO_INCREMENT; ``` Run it before adding foreign keys to those tables, as a migration rather than at boot, since it rebuilds the table under a metadata lock. A key filled by `onInsert` is no longer auto-increment, and `serial`, `bigserial` and `smallserial` are gone as `columnType`: declare the width, `@Id({ type: Number })` or `@Id({ type: Number, columnType: 'int' })`. ## 0.44.0 - 2026-09-07 A naming strategy no longer rewrites a table you named yourself, so check which table `@Entity({ name: 'UserProfile' })` entities now map to; ones that name no table are unaffected. `migrator.autoSync`, `syncForce` and `syncEntity` are one `migrator.sync(options)`. ## 0.43.0 - 2026-09-07 An option a column cannot use is now an error where it used to be silently ignored. Delete `length`, `precision`, `scale`, `autoIncrement`, `dimensions` and `distance` where the column’s type has no use for them, any DDL or generator option on a `virtual` field, `onUpdate` beside `updatable: false`, `nullable: true` on a key, and a `defaultValue` that is not the value the column holds (a JSON column keeps the SQL literal it stores, `defaultValue: '{}'`). ## 0.42.1 - 2026-09-05 Nothing in your database is renamed or rewritten: keys and indexes are recognised by the columns they cover, never by name. Adding a key column to a table that already has rows fails until you fill it in. ## 0.42.0 - 2026-09-04 A second `@Id` composes the key instead of replacing the first, so an entity relying on that gains a column and a two-column [`PRIMARY KEY`](https://uql-orm.dev/entities/basic.md#composite-primary-keys); narrowing an *inherited* key is unaffected. `EntityMeta.id` is now `ids`, a list. ## 0.24.0 - 2026-08-02 The pool runs every operation, so a function that writes can take `UniversalQuerier` (a querier or the pool). Only a hand-written `QuerierPool` has anything to do: `insertMany`, `updateMany`, `upsertOne`, `upsertMany` and `saveMany` are no longer optional. ## 0.23.0 - 2026-08-01 **Decorators are the standard TC39 ones now.** No `experimentalDecorators`, no `emitDecoratorMetadata`, no `reflect-metadata`. The [codemod](https://uql-orm.dev/codemod.md) does most of it and reports what is left. Then, by hand: - **`target` must not be `esnext`**, the one target where TypeScript leaves decorators untransformed. - **Your build must transform them.** esbuild, SWC, Babel `version: '2023-11'`, Bun and `tsc` do. **Oxc does not**, so Vite 8 needs one of the others. - **`uql.config.ts` needs `bun` or `node --import tsx`** if it imports entities, since decorators are not erasable syntax. - **NestJS: use `defineEntity`.** Nest’s DI needs parameter decorators, and one `tsconfig.json` cannot mix specs. See [NestJS](https://uql-orm.dev/nestjs.md). - **Node 24 is the minimum.** # Codemod > One command that rewrites your entities across the breaking changes that can be made mechanically. Source: https://uql-orm.dev/codemod Dry run first: ```sh npx uql-codemod --dry-run ``` ```plaintext needs a decision: src/entity/audit.ts:9: cannot infer 'type' for Json 23 file(s) would change 1 property(ies) left untouched; see above ``` `needs a decision:` is something it will not guess at, and the run exits `1` while any remain. `worth a look:` is a rewrite it did make that you may still want to read. Then drop the flag to write the changes. Edits are spliced into the text rather than the file being reprinted, so everything it does not touch stays byte for byte as you wrote it, comments and formatting included. Point it elsewhere with `--project=`, or narrow it to certain paths with `--include=`. It reads a real `tsconfig.json` rather than parsing one, because half the job is writing down types and only the checker knows what `role?: Role` resolves to. ## What it rewrites - `type` on a `@Field`/`@Id` that has none, from the property’s declared type. - `entity: () => X` on a relation, and `Relation` to `T`. - `virtual` to [`computed`](https://uql-orm.dev/entities/computed-fields.md), `raw('SQL')` to the [tagged template](https://uql-orm.dev/querying/raw-sql.md), and `raw(fn, alias)` to `raw(fn).as(alias)`. - A check’s `expression` and a filter’s `condition` to `where`, and `$lock: { wait: 'skip' }` to `{ $wait: 'skip' }`, or to `true` for a lock that waits. - Members named by string to callbacks: `mappedBy`, `references`, `@Index` and `@Entity`/`defineEntity`’s `indexes` and `hooks`, as in `@Index((post) => [post.title])`. - `references: (post) => post.authorId` on each to-one, declaring `@Field({ references: () => User }) authorId` first where the entity has no such column, typed as the key it points at. - A partial-index `where` string to `raw`, on entities and in the [migration builder](https://uql-orm.dev/migrations/builder.md). - `col('cost')` to the [ref](https://uql-orm.dev/querying/raw-sql.md) it names: ``(product) => raw`${product.cost}` ``in a `computed`, and `refs(Item).cost` in a statement. - An aggregate’s `$agg` to `$select`, and each field it or `$text`’s `$fields` names to a key: `{ $sum: { amount: true } }`. - Renamed exports, import and uses: `QueryWhereMap`, `RelationKeyMap`, `FilterCondition`, `SqlMigrationModuleOptions`, `buildSqlQuerierMigrationModule`, and each removed driver class (`PgDialect`, `NeonQuerier`, `LibsqlQuerier`, …) to the one it extended, from the entry exporting it. - `| null` on the property of each [nullable column](https://uql-orm.dev/entities/basic.md#nullable-columns): every field but a key, a relation aggregate, and one declaring `nullable: false`. - The [`idKey` brand](https://uql-orm.dev/entities/basic.md#naming-the-key) on a key not called `id`, `_id` or `uuid`, and on every composite. - Deletes `import 'reflect-metadata'`, and `experimentalDecorators`/`emitDecoratorMetadata` from your `tsconfig.json`. ## What it leaves to you Anything where the choice is yours, reported rather than guessed. It exits `1` when something is left, `0` when nothing is, and `2` when it could not start at all. - `@Transactional()`, `@InjectQuerier()`, `@Log()` and `@Serialized()`, which no longer exist. A `@Transactional()` method becomes a `pool.transaction()` around its body, and only you know which pool. - An export removed with no one-to-one replacement, such as `setQuerierPool` or `AbstractPgQuerier`. - A `col()` whose entity it cannot tell: a raw built apart from its query, one under a relation filter, or a column no field maps. Read it off [`refs(Entity)`](https://uql-orm.dev/querying/raw-sql.md). - A value it cannot read: `@Field(shared)`, a spread, or a list that is not a literal. - A partial-index `where` template that interpolates, since `raw` would treat each interpolation as a value. - SQL in a definition that names a column by hand, noted as `worth a look:`. Only a ref follows a rename: ``(user) => raw`lower(${user.email})` ``. - A to-one onto a composite key: declare a column per key and pair each in `references`. Likewise a to-one whose `Id` is not a declared column: an undecorated property, or one missing from `defineEntity`’s `fields`. - A property whose type maps to no column, and a key written under a computed name. - A nullable column’s property with no type written, which it cannot append `| null` to. - A branded string id, written as `type: String` to keep the column you have. `'uuid'` is a native column, and a migration. - `target: esnext`, the one target that leaves decorators untransformed. Any other modern one works. ## Then run `tsc` That is the rest of the migration, and the point of it. Everything the codemod inserts is checked against the property it describes, so anything it got wrong is a compile error rather than a quietly wrong column. What each release asks beyond this is in the [upgrade guide](https://uql-orm.dev/upgrade-guide.md). # AI & RAG > Build semantic search and RAG features in UQL with one type-safe query API. Source: https://uql-orm.dev/ai-semantic-search ## Semantic search inside your ORM Embeddings are a column type and similarity is a sort, so searching by meaning is an ordinary query rather than a separate search stack to run and keep in sync. The same query runs on every engine with vector support, and on every runtime UQL runs on, including the browser. This page walks one RAG feature end to end. For the operators, distance metrics and per-dialect index tuning, see the [semantic search reference](https://uql-orm.dev/querying/semantic-search.md). --- ## End-to-end example ### 1. Define an entity with a vector field A vector field is a `@Field` with `dimensions`. Index it so lookups are approximate-nearest-neighbor rather than a full scan; the tuning parameters are per-dialect, covered in [vector indexes](https://uql-orm.dev/querying/semantic-search.md#vector-indexes): ```ts import { Entity, Id, Field, Index } from 'uql-orm'; @Entity() @Index((article) => [article.embedding], { type: 'hnsw', distance: 'cosine', m: 16, efConstruction: 64, }) export class Article { @Id({ type: Number }) id?: number; @Field({ type: String }) title?: string | null; @Field({ type: String }) category?: string | null; @Field({ type: 'vector', dimensions: 1536 }) embedding?: number[] | null; } ``` `hnsw` is pgvector’s index (PostgreSQL, PGlite); CockroachDB, libSQL and Turso Cloud build it as their own vector index, and MariaDB declares `type: 'vector'`. Plain SQLite, the embedded Turso engine and MSSQL have none, so every query computes the distance exactly: leave the index off MSSQL, whose migrations refuse it. ### 2. Ingest content with embeddings A single operation runs [directly on the pool](https://uql-orm.dev/querying/querier.md#choosing-poolx-vs-querierx); [`pool.withQuerier()`](https://uql-orm.dev/querying/querier.md) pins one connection for several. ```ts import { pool } from './uql.config.js'; import { Article } from './entities.js'; const embedding = await embed('What is UQL?'); // any embedding model await pool.insertOne(Article, { title: 'What is UQL?', category: 'docs', embedding, }); ``` ### 3. Query by meaning ```ts import { pool } from './uql.config.js'; import { Article } from './entities.js'; import type { WithProjection } from 'uql-orm'; const queryEmbedding = await embed('TypeScript ORM with vector search'); // any embedding model const results = (await pool.findMany(Article, { $where: { category: 'docs' }, $sort: { embedding: { $vector: queryEmbedding, $distance: 'cosine', $project: 'distance', }, }, $limit: 10, })) as WithProjection[]; for (const article of results) { console.log(article.title, article.distance); } ``` `$project` adds the computed score to each row so your app can filter and rank on it, and [`WithProjection`](https://uql-orm.dev/querying/semantic-search.md#distance-projection) types that extra field. --- ## Production tips ### Threshold in the database, not in your app For RAG, keep low-signal results out of your context window with [`$near`](https://uql-orm.dev/querying/semantic-search.md#distance-predicate), so the threshold runs where the rows are instead of over rows you already paid to transfer: ```ts import type { WithProjection } from 'uql-orm'; const context = (await pool.findMany(Article, { $where: { category: 'docs', embedding: { $near: { $vector: queryEmbedding, $distance: 'cosine', $lt: 0.35 }, }, }, $sort: { embedding: { $vector: queryEmbedding, $distance: 'cosine', $project: 'score', }, }, $limit: 30, })) as WithProjection[]; ``` With cosine distance, lower values are better matches, so `$lt` is the bound you want. Tune it from real logs and user feedback. `$sort` still ranks what survives, and `$project` returns the score so you can show or log it. The `category` filter narrows the candidate set before ranking; [combined with filtering](https://uql-orm.dev/querying/semantic-search.md#combined-with-filtering) shows how each database executes that. > **On MongoDB Atlas, threshold on the score instead** > > Atlas ranks by an index-defined similarity rather than a distance, and that scale lives in the Atlas index definition, which UQL never sees. `$near` throws there rather than guess it. Project the score with `$project` and filter on it in your app, which is what the score is for. # AI Coding Agents > Give your coding agent the UQL skill, the docs as Markdown, and a docs MCP server, so it writes UQL for the version you installed. Source: https://uql-orm.dev/ai-agents Two things: the skill, which teaches it how UQL code is well written, and the docs, which it looks up as it goes. Paste this into any coding agent and it does both, then proves it worked: ```text title="Setup prompt" Set this repository up for UQL (uql-orm): 1. Add https://uql-orm.dev/mcp as an MCP server for this agent: HTTP transport, no key. 2. Add this line to AGENTS.md: Before writing UQL code, read `node_modules/uql-orm/skills/uql-orm/SKILL.md`: it matches the installed uql-orm. 3. Call the server's read_skill tool and tell me the first heading it returns. ``` Or do each half yourself. ## 1. Point it at the skill `uql-orm` ships an [agent skill](https://uql-orm.dev/.well-known/agent-skills/uql-orm/SKILL.md): how a query is shaped, how entities are declared, the mistakes agents make most, and where each detail lives in these docs. It lives inside the package, so point your agent at it once and every `uql-orm` upgrade brings the skill for that version with it. Add this line to your project’s `AGENTS.md`, which Claude Code, Codex, Cursor, GitHub Copilot and most other agents read on their own: ```md title="AGENTS.md" Before writing UQL code, read `node_modules/uql-orm/skills/uql-orm/SKILL.md`: it matches the installed uql-orm. ``` ## 2. Let it look up the docs ### Over MCP The docs MCP server at `https://uql-orm.dev/mcp` adds search, so the agent finds the right page without knowing its URL. All three tools are read-only, and it needs no key: | Tool | What it answers | | - | - | | `search_docs` | The pages for a topic, most relevant first, with the passage that matched | | `get_doc` | One page as Markdown, by the path `search_docs` gives or by its URL | | `read_skill` | The skill above, for a client that cannot install one | One click installs it in Cursor or VS Code: [Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=UQL%20docs\&config=eyJ1cmwiOiJodHRwczovL3VxbC1vcm0uZGV2L21jcCJ9) [VS Code](vscode:mcp/install?%7B%22name%22%3A%22UQL%20docs%22%2C%22url%22%3A%22https%3A%2F%2Fuql-orm.dev%2Fmcp%22%7D) In a terminal agent, one command: Claude Code: ```sh claude mcp add --transport http uql https://uql-orm.dev/mcp ``` Codex: ```sh codex mcp add uql --url https://uql-orm.dev/mcp ``` In Claude.ai or Claude Desktop, add it as a custom connector (**Customize**, then **Connectors**). Any other client takes the same URL in its MCP settings. ### Or simply by URL, with nothing to set up Every page here answers with Markdown when an agent asks for it, as Claude Code and Cursor do on their own. It is also Markdown at its own URL plus `.md`, such as [`/querying/relations.md`](https://uql-orm.dev/querying/relations.md), and the index of them all is [`/llms.txt`](https://uql-orm.dev/llms.txt). An agent that can fetch URLs needs nothing more than that index: ```text Before writing UQL code, read https://uql-orm.dev/llms.txt and fetch the pages for the task. ``` To load the whole site in one go instead, [`/llms-full.txt`](https://uql-orm.dev/llms-full.txt) is every page in one file. # FAQ > Frequently asked questions about UQL ORM Source: https://uql-orm.dev/faq ## Getting Started ### What is UQL, and why would I pick it? A UQL query is plain, fully typed (JSON) data, not a method chain. That one decision gives it four things most ORMs trade against each other: - **The most portable**: the same query object runs on PostgreSQL, MySQL, MariaDB, MSSQL, SQLite, MongoDB, and the edge, and travels over HTTP between server and client unchanged. - **The most capable out of the box**: [native semantic and vector search](https://uql-orm.dev/ai-semantic-search.md), [non-bypassable multi-tenant security filters](https://uql-orm.dev/multi-tenancy.md), [soft-delete with restore](https://uql-orm.dev/entities/soft-delete.md), and [entity-first migrations](https://uql-orm.dev/migrations.md), which are raw SQL, a plugin, or missing elsewhere. An optional [REST API](https://uql-orm.dev/http.md) and [typed browser client](https://uql-orm.dev/browser.md) ship in the same package. - **The fastest ORM**: adds the least over hand-written driver code of any ORM in our [full PostgreSQL round trip benchmark](https://uql-orm.dev/benchmark.md). - **The lowest-friction**: no codegen, no DSL, no build step. Your TypeScript classes are the schema. Drizzle picks lean and fast; Prisma and TypeORM pick full-featured and heavy. UQL is built so you don’t pick. ### Where does the name UQL come from? Unified Query Language: one type-safe JSON syntax, inspired by MongoDB’s, for SQL databases and MongoDB alike. That is the [first of the five things a perfect ORM should have](https://uql-orm.dev/blog/in-search-of-the-perfect-orm.md), and the reason the same query object runs on the server, on the edge, and in the browser. ### How is UQL different from Drizzle, Prisma, or TypeORM? The query is JSON rather than a method chain, there is no codegen, and one syntax covers every SQL engine and MongoDB. The [comparison page](https://uql-orm.dev/comparison.md) sets every operation side by side, MikroORM included, with a feature matrix. ### Is UQL production-ready? Yes. UQL runs in production behind [Variability.ai](https://variability.ai), an AI meeting notetaker built by UQL’s author. --- ## Installation & Setup ### Which database drivers do I need? Your database’s own Node driver (`pg`, `mysql2`, `mariadb`, `better-sqlite3`, `mssql`, `mongodb`, …); [Which pool](https://uql-orm.dev/pool.md#which-pool) lists every entry point with the driver it takes. On Bun, `bun:sql` covers PostgreSQL, MySQL and MariaDB, and `bun:sqlite` covers SQLite, so no driver is needed; D1 is the Worker’s own binding. ### Do I need special TypeScript configuration? No decorator flags or polyfills: UQL uses the [standard TC39 decorators](https://uql-orm.dev/entities/basic.md). Three settings do matter, `module`, `target` and `lib`, and [Requirements](https://uql-orm.dev/getting-started.md#requirements) lists them with the reason for each. ### Can I use UQL from JavaScript? Yes, through [`defineEntity`](https://uql-orm.dev/entities/imperative.md). UQL reads no TypeScript types at runtime, so a plain class registers the same metadata a decorated one does, and the CLI reads a `uql.config.js` like a `.ts` one. [Decorators](https://uql-orm.dev/entities/basic.md) are the exception: no JavaScript engine implements them yet, so a `.js` file using them needs a transpiler. Bun transpiles every file it runs; elsewhere use Babel, SWC or esbuild. --- ## Core Concepts ### What does “JSON-native” mean? A UQL query is a plain JavaScript object: ```ts import type { Query } from 'uql-orm/type'; import { User } from './shared/models/index.js'; const query: Query = { $select: { id: true, name: true }, $where: { email: { $endsWith: '@uql-orm.dev' } }, $sort: { createdAt: 'desc' }, $limit: 10, }; ``` Because the query is data, you can `JSON.stringify()` it, send it over HTTP, cache it, diff it, or share it between backend and frontend. ### How do I expose entities over HTTP or query from the browser? The [HTTP transport core](https://uql-orm.dev/http.md) serves your entities as a REST API from any framework (Express, Hono, Next.js, Bun, Workers, …), with hooks for auth and tenant scoping. On the frontend, [`HttpQuerier`](https://uql-orm.dev/browser.md) consumes that API with the same type-safe query syntax you use on the backend. ### What’s the difference between `type` and `columnType`? `type` is the portable, logical type the compiler checks the property against, and it is always required; `columnType` overrides the SQL type it maps to, for exact control. See [Type Abstraction](https://uql-orm.dev/entities/basic.md#type-abstraction). ### What’s the difference between `$select` and `$populate`? - **`$select`**: Scalar fields (strings, numbers, dates, JSON) - **`$populate`**: Related entities (relations) ```ts const query: Query = { $select: { id: true, name: true }, // scalar fields $populate: { posts: { $select: { title: true } } }, // relations }; ``` --- ## Queries & Relations ### How do I filter by nested JSON properties? Use dot-notation paths in `$where`: ```ts await pool.findMany(Company, { $where: { 'settings.isArchived': { $ne: true }, 'settings.theme': 'dark', }, }); ``` Works the same way on every SQL dialect UQL supports. ### How do I join relations? ```ts await pool.findMany(Post, { $select: { id: true, title: true }, $populate: { author: { $select: { id: true, name: true } }, }, $where: { author: { name: 'Roger' } }, }); ``` One query with a `JOIN`, not one query per row. ### How do I filter by how many related records exist? ```ts await pool.findMany(MeasureUnitCategory, { $where: { measureUnits: { $size: { $gte: 2 } }, }, }); ``` `$size` compiles to a `COUNT(*)` subquery, so “categories with at least 2 measure units” never has to load the relation to check. --- ## Migrations & Schema ### Do I need to write SQL migrations manually? No. UQL uses an **Entity-First** approach: ```bash # 1. Update your entity class # 2. Auto-generate the migration npx uql-migrate generate:entities add_user_nickname # 3. Apply it npx uql-migrate up ``` ### Can UQL create the database from my entities? Yes, and it is the same diff the migration generator uses, applied directly: ```bash npx uql-migrate sync --dry-run # print the DDL npx uql-migrate sync # create the missing tables, columns, and indexes ``` It only adds, so it will not drop a column or alter a type. Use it on a prototype or a test database, and [a generated migration](https://uql-orm.dev/migrations.md#from-entities-to-the-database) once there is data you would miss. For the reverse direction, `generate:from-db` writes entity classes from tables that already exist. ### Can I still write manual migrations? Yes. Use `generate` for manual SQL: ```bash npx uql-migrate generate seed_default_roles # Edit the generated file npx uql-migrate up ``` --- ## Advanced Features ### How does vector search work? A vector is a `@Field({ type: 'vector', dimensions })` and similarity is a `$sort` with `$vector`: the same query on PostgreSQL and PGlite (pgvector), CockroachDB, MariaDB, SQLite (sqlite-vec), libSQL/Turso, MSSQL (SQL Server 2025) and MongoDB Atlas. [AI & RAG](https://uql-orm.dev/ai-semantic-search.md) walks one feature end to end, and [Semantic Search](https://uql-orm.dev/querying/semantic-search.md) is the reference. ### Does UQL support soft delete, restore, and multi-tenancy? Yes. Mark a field with `@Field({ softDelete: true })` and deletes soft-delete automatically, reads hide trashed rows, and `restoreOneById` / `restoreMany` bring them back (`{ hardDelete: true }` removes for good). Soft-delete is one instance of UQL’s general [query filters](https://uql-orm.dev/querying/filters.md), which are named, default-on `$where` fragments. Mark a filter `security` and resolve it from a per-request context and you have [multi-tenancy / row-level security](https://uql-orm.dev/multi-tenancy.md): applied to every query, relations and cascades included, non-bypassable from the client, and fail-closed when the context is missing. ### Is UQL safe from SQL injection? Yes. Every value in a query is bound as a parameter and handed to the driver separately, never concatenated into the statement. Table and column names come from your entity metadata, not the query, so they cannot be injected either. The one exception is the programmatic [`raw`](https://uql-orm.dev/querying/raw-sql.md), a deliberate opt-out. It is a tagged template: you control the literal text, every interpolated value is bound, and columns come from `refs`: ```ts import { raw, refs } from 'uql-orm'; const item = refs(Item); raw`${item.stock} - ${quantity}`; ``` Its callback form does not bind, so never build one from user input; inside a callback, bind with `ctx.addValue()`. UQL binds parameters but issues no server-side prepared statements, which is also why transaction-mode poolers such as [Supabase’s](https://uql-orm.dev/supabase.md) work with no extra configuration. ### What’s the performance like? In our [open benchmark](https://uql-orm.dev/benchmark.md), which times a full PostgreSQL lifecycle, UQL adds less over hand-written driver code than any other ORM measured; the benchmark page says why. --- ## Troubleshooting ### Why am I getting “Decorators not working”? 1. Check `target` is not `esnext` (see [Requirements](https://uql-orm.dev/getting-started.md#requirements)); that is the one setting that silently breaks them 2. If your bundler transforms TypeScript itself, confirm it implements standard decorators: esbuild, SWC, Babel (`version: '2023-11'`) and Bun all do, but Oxc (Vite 8’s own transformer) does not, so a Vite 8 build needs one of the others via a plugin ### Why am I getting “Cannot find module ‘uql-orm’”? 1. Set `"module": "nodenext"`, or `"preserve"` behind a bundler. `"esnext"` on TypeScript 5.x leaves the resolver unable to read the package’s `exports` map, and there is no `require()` path to fall back to 2. Ensure `"type": "module"` in `package.json` if Node runs the compiled output 3. Under `nodenext`, use `.js` extensions in relative imports (TypeScript resolves them to `.ts`) ### Why am I getting “Cannot find global type ‘AsyncDisposable’”? `await using` needs those typings and no dated `target` pulls them in on its own. Add `"lib": ["esnext"]`. ### Why am I getting “Connection refused”? 1. Verify your database is running 2. Check credentials in `uql.config.ts` 3. Ensure the database exists (`createdb your_db`) 4. For Docker, verify network settings and port mappings ### Why is my query slow? 1. Check if you’re missing indexes on filtered columns 2. Use `findManyStream` for large result sets 3. Consider pagination with `$limit` and `$skip` 4. Enable [query logging](https://uql-orm.dev/logging.md) to see the generated SQL and per-query timings # ORM Comparison by Feature > Side-by-side API comparison of Drizzle vs MikroORM vs Prisma vs TypeORM vs UQL, with actual code for every common operation. Source: https://uql-orm.dev/comparison Every common operation, side by side for [Drizzle](https://orm.drizzle.team), [MikroORM](https://mikro-orm.io), [Prisma](https://www.prisma.io), [TypeORM](https://typeorm.io), and UQL. All the samples appear in alphabetical order. Versions compared: Drizzle `0.45.2`, MikroORM `7.2.1`, Prisma `7.10.0`, TypeORM `1.1.1`, UQL `0.77.1`. ## Schema definition ```ts title="Drizzle" // The most direct control over the SQL, through dialect-specific helpers. import { pgTable, serial, varchar, integer } from 'drizzle-orm/pg-core'; export const users = pgTable('users', { id: serial('id').primaryKey(), email: varchar('email', { length: 255 }).unique(), name: varchar('name', { length: 255 }), companyId: integer('company_id').references(() => companies.id), }); ``` ```ts title="MikroORM" // v7 adds `defineEntity`; the decorators have moved to a separate package. import { defineEntity, p } from '@mikro-orm/core'; const UserSchema = defineEntity({ name: 'User', properties: { id: p.integer().primary(), email: p.string().unique(), name: p.string(), company: () => p.manyToOne(Company), }, }); export class User extends UserSchema.class {} UserSchema.setClass(User); ``` ```prisma title="Prisma" // The shortest to read, but a language of its own with a build step behind it. model User { id Int @id @default(autoincrement()) email String @unique name String? companyId Int company Company @relation(fields: [companyId], references: [id]) } ``` ```ts title="TypeORM" // Needs `experimentalDecorators` and `emitDecoratorMetadata` in tsconfig, unless you // use `EntitySchema`, which takes the same metadata as an object. import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm'; @Entity() export class User { @PrimaryGeneratedColumn() id: number; @Column({ unique: true }) email: string; @Column() name: string; @ManyToOne(() => Company) company: Company; } ``` ```ts title="UQL" // Standard TC39 decorators, so no compiler flags to turn on. Or no decorators at all // with `defineEntity`, which registers the same metadata imperatively (/entities/imperative). import { Entity, Id, Field, ManyToOne } from 'uql-orm'; @Entity() export class User { @Id({ type: Number }) id?: number; @Field({ type: String, unique: true }) email?: string | null; @Field({ type: String }) name?: string | null; @Field({ type: Number, references: () => Company }) companyId?: number | null; @ManyToOne({ entity: () => Company, references: (user) => user.companyId }) company?: Company; } ``` Only Prisma leaves TypeScript to describe a schema. Everything else here is a file you already know how to read. --- ## Indexes One email per tenant, counting only the rows that are not soft-deleted: a composite, unique, partial index. ```ts title="Drizzle" // The columns are typed references; the predicate is an `sql` fragment over them. import { sql } from 'drizzle-orm'; import { pgTable, serial, varchar, integer, timestamp, uniqueIndex, } from 'drizzle-orm/pg-core'; export const users = pgTable( 'users', { id: serial('id').primaryKey(), email: varchar('email', { length: 255 }), tenantId: integer('tenant_id'), deletedAt: timestamp('deleted_at'), }, (t) => [ uniqueIndex('users_email_tenant_idx') .on(t.email, t.tenantId) .where(sql`${t.deletedAt} IS NULL`), ], ); ``` ```ts title="MikroORM" // The property names are checked; the `where` object is not, so an unknown key or a // string for a date compiles all the same. import { defineEntity, p } from '@mikro-orm/core'; const UserSchema = defineEntity({ name: 'User', properties: { id: p.integer().primary(), email: p.string(), tenantId: p.integer(), deletedAt: p.datetime().nullable(), }, uniques: [{ properties: ['email', 'tenantId'], where: { deletedAt: null } }], }); ``` ```prisma title="Prisma" // `where` is behind the `partialIndexes` preview feature. The object form is validated // against the fields' types, but takes only literals, `null` and `{ not }`; the rest is `raw("...")`. model User { id Int @id @default(autoincrement()) email String tenantId Int deletedAt DateTime? @@unique([email, tenantId], where: { deletedAt: null }) } ``` ```ts title="TypeORM" // The columns and the predicate are both strings, the predicate in the database's own SQL. import { Entity, Index, PrimaryGeneratedColumn, Column } from 'typeorm'; @Entity() @Index(['email', 'tenantId'], { unique: true, where: '"deletedAt" IS NULL' }) export class User { @PrimaryGeneratedColumn() id: number; @Column() email: string; @Column() tenantId: number; @Column({ nullable: true }) deletedAt: Date; } ``` ```ts title="UQL" // The columns are members and the predicate is a `$where`, both checked against the // entity, so `where: { deletedAt: 'yes' }` does not compile. import { Entity, Id, Field, Index } from 'uql-orm'; @Index((user) => [user.email, user.tenantId], { unique: true, where: { deletedAt: null }, }) @Entity() export class User { @Id({ type: Number }) id?: number; @Field({ type: String }) email?: string | null; @Field({ type: Number }) tenantId?: number | null; @Field({ type: Date, softDelete: true }) deletedAt?: Date | null; } ``` Declaring the index has converged: all five take a composite, unique, partial one. What differs is how much of it gets checked. The predicate is raw SQL in Drizzle and TypeORM, an unchecked object in MikroORM, a validated but narrow object in Prisma, and a typed `$where` with every operator in UQL. MySQL has no partial index at all. MikroORM emulates one with a functional index that is unique only where the predicate holds, Prisma refuses the `where`, and UQL throws when the migration is generated rather than widen the index to the whole table. Which of these declarations follow a rename is measured on [Rename Safety](https://uql-orm.dev/rename-safety.md), and the rest of what `@Index` takes is on [Indexes](https://uql-orm.dev/entities/indexes.md). --- ## Semantic search ```ts title="Drizzle" // pgvector column types, five distance helpers, and an HNSW/IVFFlat index declared in // the schema for `drizzle-kit` to emit. PostgreSQL only. import { cosineDistance } from 'drizzle-orm'; const results = await db .select() .from(items) .orderBy(cosineDistance(items.embedding, queryVector)) .limit(10); ``` ```ts title="MikroORM" // Same operators, from pgvector's own adapter rather than core. PostgreSQL only. import { cosineDistance } from 'pgvector/mikro-orm'; const results = await em .createQueryBuilder(Item) .select('*') .orderBy({ [cosineDistance('embedding', queryVector)]: 'ASC' }) .limit(10) .getResult(); ``` ```ts title="Prisma" // No vector operator, so this stays as raw SQL for every specific DB. const results = await prisma.$queryRaw` SELECT * FROM "Item" ORDER BY embedding <=> ${queryVector}::vector LIMIT 10 `; ``` ```ts title="TypeORM" // Maps a `vector` column on several engines, but no distance expression on any, // so the ORDER BY is a hand-written string. const results = await manager .createQueryBuilder(Item, 'item') .orderBy('item.embedding <=> :vector') .setParameter('vector', queryVector) .limit(10) .getMany(); ``` ```ts title="UQL" // A typed operator (not a helper per DB); same query on PostgreSQL, // CockroachDB, MariaDB, SQLite, libSQL/Turso, MSSQL, and MongoDB Atlas. // `$near` bounds the distance, `$candidates` widens the index search. const results = await pool.findMany(Item, { $select: { id: true, name: true }, $where: { embedding: { $near: { $vector: queryVector, $lt: 0.35 } } }, $sort: { embedding: { $vector: queryVector, $distance: 'cosine' } }, $limit: 10, }); ``` Except for UQL, every sample here bottoms out in pgvector, which is why every one stops at PostgreSQL. UQL emits each engine’s own function instead: `VEC_DISTANCE_*` on MariaDB, `vec_distance_*` on SQLite, `vector_distance_*` on Turso and libSQL, `VECTOR_DISTANCE` on MSSQL, `$vectorSearch` on MongoDB. | Ability | **Drizzle** | **MikroORM** | **Prisma** | **TypeORM** | **UQL** | | - | - | - | - | - | - | | Native vector operator | ✅¹ | 🔌² | ❌ | ❌³ | ✅ | | Multi-dialect support | 🔌⁴ | 🔌⁴ | 🔌⁴ | 🔌⁴ | ✅ | | Index migration | ✅¹ | 🔌⁵ | 🔌⁶ | ❌ | ✅ | | Distance predicate | ✅⁷ | 🔌⁷ | ❌ | ❌ | ✅ | | Query-time ANN tuning | ❌⁸ | ❌⁸ | ❌⁸ | ❌⁸ | ✅ | | JSON path vectors | ❌ | ❌ | ❌ | ❌ | ✅ | ¹ PostgreSQL (pgvector) only. The index is declared in the schema (`index().using('hnsw', table.embedding.op('vector_cosine_ops'))`) and `drizzle-kit generate` emits the DDL. ² Through `pgvector`’s first-party `pgvector/mikro-orm` adapter, not `@mikro-orm/core`; PostgreSQL only. ³ TypeORM maps and diffs the column (`@Column('vector', { length: 3 })`) on Postgres, MySQL/MariaDB and MSSQL, but has no distance operator on any of them: the `ORDER BY` is a hand-written string. ⁴ Every helper above is written for pgvector. On MariaDB’s `VEC_DISTANCE_*`, SQLite’s `sqlite-vec`, Turso’s `vector_distance_*`, SQL Server’s `VECTOR_DISTANCE`, or MongoDB’s `$vectorSearch` you hand-write that dialect’s own SQL or pipeline. ⁵ Reachable through a raw index expression (``@Index((doc) => [raw`...`])``), not a declared vector index. ⁶ Prisma’s schema DSL still emits a plain B-tree for `@@index` on a vector column; HNSW/IVFFlat need the `Unsupported("vector(n)")` escape hatch plus a hand-written SQL migration. ⁷ Their distance helpers are plain SQL expressions, so they compose into a `where` for free, on pgvector only. UQL’s `$near` is a typed operator that works on every engine above and refuses the metrics an engine lacks. ⁸ `hnsw.ef_search` and `ivfflat.probes` decide recall at query time, and none of them expose it: you issue the `SET LOCAL` yourself inside a transaction, and remember that it needs one. --- ## Full-text search ```ts title="Drizzle" // No full-text API: a `sql` fragment in `where` and in `orderBy`. The GIN index is declared in the // schema with `index().using('gin', sql`...`)` for `drizzle-kit` to emit. PostgreSQL only. import { sql } from 'drizzle-orm'; const document = sql`to_tsvector('spanish', ${listings.title} || ' ' || ${listings.description})`; const query = sql`websearch_to_tsquery('spanish', ${term})`; const results = await db .select() .from(listings) .where(sql`${document} @@ ${query}`) .orderBy(sql`ts_rank(${document}, ${query}) desc`); ``` ```ts title="MikroORM" // `$fulltext` filters. Postgres weights (A to D) live in a tsvector column your entity fills on // every write; nothing ranks, so the order is yours to write. @Entity() class Listing { @Property({ type: new FullTextType('spanish'), onUpdate: (l) => ({ A: l.title, B: l.description }), }) search!: WeightedFullTextValue; } const results = await em.find(Listing, { search: { $fulltext: term } }); ``` ```prisma title="Prisma" generator client { previewFeatures = ["fullTextSearchPostgres"] } ``` ```ts title="Prisma" // `search` filters and `_relevance` orders, repeating the fields and the term. No weights. const results = await prisma.listing.findMany({ where: { title: { search: term } }, orderBy: { _relevance: { fields: ['title', 'description'], search: term, sort: 'desc', }, }, }); ``` ```ts title="TypeORM" // `@Index({ fulltext: true })` on MySQL only; the query is a hand-written string. const results = await dataSource .getRepository(Listing) .createQueryBuilder('listing') .where('MATCH(listing.title, listing.description) AGAINST (:term)', { term }) .getMany(); ``` ```ts title="UQL" // One `@Index({ type: 'fulltext', config: 'spanish' })` with a weight on the title, declared once. // The query names only the text; the fields, the language and the weights come from the index. const results = await pool.findMany(Listing, { $where: { $text: { $value: 'wireless keyboard' } }, $sort: { $text: { $project: 'score' } }, }); ``` UQL runs the same query on PostgreSQL, CockroachDB, MySQL, MariaDB and MongoDB, and on SQLite once an FTS5 table exists. The others stop at the engine their sample is written for, and the ranking and weights are yours to write. | Ability | **Drizzle** | **MikroORM** | **Prisma** | **TypeORM** | **UQL** | | - | - | - | - | - | - | | Search operator | ❌¹ | ✅² | ✅³ | ❌⁴ | ✅ | | Relevance sort | ❌¹ | ❌ | ✅⁵ | ❌⁴ | ✅ | | Returns the score | ❌ | ❌ | ❌ | ❌ | ✅ | | Column weights | ❌ | 🔌⁶ | ❌ | ❌ | ✅ | | Index in migrations | ✅¹ | ✅² | ✅⁷ | ✅⁴ | ✅ | | One query on every engine | ❌ | 🔌² | 🔌³ | ❌ | ✅ | ¹ Plain `sql` fragments, PostgreSQL only; the GIN expression index is declared in the schema and emitted by `drizzle-kit`. ² PostgreSQL, MySQL/MariaDB and MongoDB, through `@Index({ type: 'fulltext' })` and `$fulltext`; SQLite needs an FTS5 table created by hand. On PostgreSQL the index is over `to_tsvector('simple', ...)`. ³ Behind a preview feature, on PostgreSQL and MySQL; the query syntax is the engine’s own. ⁴ `@Index({ fulltext: true })` builds a MySQL `FULLTEXT` index, and nothing else is native: the query and the ordering are a string you write. ⁵ `_relevance` on PostgreSQL and MySQL, repeating the fields and the term the filter already names. ⁶ PostgreSQL only, as `A` to `D` labels in a `tsvector` column your entity computes on every write. ⁷ `@@fulltext` on MySQL and MongoDB; a PostgreSQL index is hand-written SQL in a migration. --- ## Find: Select & filter ```ts title="Drizzle" // Closest to the SQL, at the cost of a helper imported per operator. import { eq, desc } from 'drizzle-orm'; const results = await db .select({ id: users.id, name: users.name, email: users.email }) .from(users) .where(eq(users.name, 'Jane')) .orderBy(desc(users.createdAt)) .limit(10); ``` ```ts title="MikroORM" // Same shape, with `fields` in place of `select`; the rows come back as managed entities. const results = await em.find( User, { name: 'Jane' }, { fields: ['id', 'name', 'email'], orderBy: { createdAt: 'DESC' }, limit: 10, }, ); ``` ```ts title="Prisma" // Also plain JSON, but typed by the generated client rather than by your own classes. const results = await prisma.user.findMany({ select: { id: true, name: true, email: true }, where: { name: 'Jane' }, orderBy: { createdAt: 'desc' }, take: 10, }); ``` ```ts title="TypeORM" // Reads the same on equality; anything past it is a helper function (`Like`, `MoreThan`). const results = await manager.find(User, { select: { id: true, name: true, email: true }, where: { name: 'Jane' }, order: { createdAt: 'DESC' }, take: 10, }); ``` ```ts title="UQL" // Plain JSON, so this whole object can be built, stored, or sent over the wire. const results = await pool.findMany(User, { $select: { id: true, name: true, email: true }, $where: { name: 'Jane' }, $sort: { createdAt: 'desc' }, $limit: 10, }); ``` The declarative four read the same on every vendor, but once the filter needs an operator, TypeORM’s stops being data, so only MikroORM’s, Prisma’s and UQL’s survive `JSON.stringify`. --- ## Query: Relations ```ts title="Drizzle" // The nested `where` is an operator callback rather than an object, and works only on // to-many. Object filters and a `where` on a to-one arrive with Relational Queries v2, // still in the 1.0 beta line. const results = await db.query.users.findMany({ columns: { id: true, name: true }, with: { posts: { columns: { title: true }, where: (post, { eq }) => eq(post.published, true), }, }, }); ``` ```ts title="MikroORM" // The relation's condition is its own option, `populateFilter`, which nests as a // LEFT JOIN, so filtering the posts does not drop the users that have none. const results = await em.find( User, {}, { fields: ['id', 'name', 'posts.title'], populate: ['posts'], populateFilter: { posts: { published: true } }, }, ); ``` ```ts title="Prisma" // Inline on a to-many, but there is no `where` on a to-one. const results = await prisma.user.findMany({ select: { id: true, name: true, posts: { select: { title: true }, where: { published: true } }, }, }); ``` ```ts title="TypeORM" // find() options cannot filter a relation independently of its parent: a nested condition // filters the parents too, and gets unreliable past the first level. So an independently // filtered join needs the QueryBuilder: const results = await manager .createQueryBuilder(User, 'user') .select(['user.id', 'user.name']) .leftJoinAndSelect('user.posts', 'post', 'post.published = :published', { published: true, }) .getMany(); ``` ```ts title="UQL" // The condition sits on the join itself, at any depth and on either side of it. // `$required: true` promotes that join to an INNER JOIN. const results = await pool.findMany(User, { $select: { id: true, name: true }, $populate: { posts: { $select: { title: true }, $where: { published: true } }, }, }); ``` Filtering a to-many while you fetch it has converged. The reach has not. --- ## Aggregation & grouping ```ts title="Drizzle" // Composed from SQL helpers, one import per operator. import { count, avg, gt, gte, desc } from 'drizzle-orm'; const results = await db .select({ status: users.status, count: count(), avgAge: avg(users.age) }) .from(users) .where(gte(users.createdAt, new Date('2025-01-01'))) .groupBy(users.status) .having(({ avgAge }) => gt(avgAge, 30)) .orderBy(desc(count())) .limit(10); ``` ```ts title="MikroORM" // QueryBuilder only. v7 tracks the raw aliases in the type, so `having` is checked // against them, but the values come back as `unknown` and still need narrowing. import { sql } from '@mikro-orm/core'; const results = await em .createQueryBuilder(User, 'u') .select(['u.status', sql`count(*)`.as('count'), sql`avg(u.age)`.as('avgAge')]) .where({ createdAt: { $gte: new Date('2025-01-01') } }) .groupBy('u.status') .having({ avgAge: { $gt: 30 } }) .orderBy({ count: 'desc' }) .limit(10) .execute('all'); ``` ```ts title="Prisma" // Declarative and type-safe, but keyed by operator (`_avg`, `_count`) and SQL-only. const results = await prisma.user.groupBy({ by: ['status'], where: { createdAt: { gte: new Date('2025-01-01') } }, _count: { status: true }, _avg: { age: true }, having: { age: { _avg: { gt: 30 } } }, orderBy: { _count: { status: 'desc' } }, take: 10, }); ``` ```ts title="TypeORM" // QueryBuilder only, `having` and `groupBy` are strings, and the rows arrive // untyped: getRawMany() returns any[]. import { MoreThanOrEqual } from 'typeorm'; const results = await manager .createQueryBuilder(User, 'user') .select('user.status', 'status') .addSelect('COUNT(*)', 'count') .addSelect('AVG(user.age)', 'avgAge') .where({ createdAt: MoreThanOrEqual(new Date('2025-01-01')) }) .groupBy('user.status') .having('AVG(user.age) > :minAge', { minAge: 30 }) .orderBy('count', 'DESC') .limit(10) .getRawMany(); ``` ```ts title="UQL" // Plain JSON, identical on every SQL engine and MongoDB, and typed end to end: the // `$group` columns, the `$select` aggregates, the `$having` and `$sort` aliases, and the rows. const results = await pool.aggregate(User, { $where: { createdAt: { $gte: new Date('2025-01-01') } }, $group: { status: true }, $select: { count: { $count: '*' }, avgAge: { $avg: { age: true } } }, $having: { avgAge: { $gt: 30 } }, $sort: { count: -1 }, $limit: 10, }); ``` Only Prisma and UQL express aggregation declaratively; the rest reach for a query builder. One thing the samples do not show: in UQL, `$where` in `aggregate()` runs through the same filter engine as `findMany`, so [soft-delete, default, and `security` filters](https://uql-orm.dev/querying/filters.md) apply here too. Tenant scoping does not quietly stop at `GROUP BY`. --- ## Counting ```ts title="Drizzle" // `$count` is a query of its own. A count per parent is a correlated subquery you write as // an extra, and ordering by it repeats that subquery in the ORDER BY. import { sql, eq } from 'drizzle-orm'; const total = await db.$count(users, eq(users.status, 'active')); const results = await db.query.users.findMany({ extras: { postCount: sql`(SELECT count(*) FROM posts WHERE posts.author_id = ${users.id})`.as( 'post_count', ), }, }); ``` ```ts title="MikroORM" // `count` and `findAndCount` are there; there is no existence check short of counting. A // per-parent tally is `loadCount` on each collection, so it is a query per parent. const total = await em.count(User, { status: 'active' }); const [page, count] = await em.findAndCount(User, {}, { limit: 10 }); const postCount = await user.posts.loadCount(); ``` ```ts title="Prisma" // The closest of the four: `_count` selects relation tallies, takes a filter of its own, and // `orderBy` accepts one. No cheap existence check and no estimate: both are a full count or raw SQL. const results = await prisma.user.findMany({ select: { id: true, _count: { select: { posts: true } } }, orderBy: { posts: { _count: 'desc' } }, take: 10, }); ``` ```ts title="TypeORM" // `count`, `findAndCount`, `exists` and `existsBy` are all there. A per-parent tally is not: // `loadRelationCountAndMap` went with 1.0, so it is a COUNT with its own GROUP BY, returned // as raw rows you merge back onto the entities yourself. const total = await manager.count(User, { where: { status: 'active' } }); const any = await manager.exists(User, { where: { name: 'Jane' } }); const counts = await manager .createQueryBuilder(User, 'user') .select('user.id', 'id') .addSelect('COUNT(post.id)', 'postCount') .leftJoin('user.posts', 'post') .groupBy('user.id') .getRawMany(); ``` ```ts title="UQL" // Tallies are part of the read: per relation, under `_count`, and sortable by the same name // without loading the rows they rank by. const results = await pool.findMany(User, { $count: { posts: true }, // or a filter of its own: { posts: { $where: { ... } } } $sort: { posts: { $count: -1 } }, // the users with the most posts $limit: 10, }); await pool.exists(User, { $where: { name: 'Jane' } }); // stops at the first match await pool.count(User, { $limit: 1000 }); // capped: "1,000+ matches", no full scan await pool.estimatedCount(User); // the engine's own statistic, no scan ``` Every one of them counts rows. What differs is the cost of the three questions around it: how many rows each parent has, whether there is at least one, and roughly how big the table is. Prisma and UQL are the two where a relation tally is part of the read itself: named, filtered, and sorted by without writing the subquery. UQL reads it in the row’s own statement. `exists` stops at the first match rather than counting to the end, [`count`](https://uql-orm.dev/querying/counting.md) takes a `$limit` so “1,000+ matches” never scans a large table, and `estimatedCount` reads the engine’s own statistic (approximate, whole-table, and as stale as the last `ANALYZE`). `findManyAndCount` is a single statement on SQL, so a page and its total cannot disagree. --- ## Computed fields ```ts title="Drizzle" import { sql, type SQL } from 'drizzle-orm'; // Two separate cases. Per query, as a selected expression you restate every time: const results = await db .select({ id: users.id, fullName: sql`CONCAT(${users.firstName}, ' ', ${users.lastName})`, }) .from(users); // Or once in the schema, as a generated column. Filterable and sortable, but it is a // real column, so it needs a migration (and `stored` on PostgreSQL): export const users = pgTable('users', { firstName: varchar('first_name', { length: 255 }), lastName: varchar('last_name', { length: 255 }), fullName: varchar('full_name', { length: 511 }).generatedAlwaysAs( (): SQL => sql`${users.firstName} || ' ' || ${users.lastName}`, ), }); ``` ```ts title="MikroORM" // A formula property: the expression goes into the SQL, not into JS, so it selects, // filters, and sorts like a real column with no migration behind it. import { defineEntity, p } from '@mikro-orm/core'; const UserSchema = defineEntity({ name: 'User', properties: { firstName: p.string(), lastName: p.string(), // `cols.x` expands to the quoted, alias-qualified column fullName: p.formula( (cols) => `CONCAT(${cols.firstName}, ' ', ${cols.lastName})`, ), }, }); export class User extends UserSchema.class {} UserSchema.setClass(User); ``` ```ts title="Prisma" // The only one with no database-side option, so you map after the fetch. A // `$extends({ result })` extension centralizes this and is type-safe, but it still // computes in JS, so you cannot filter or sort by the value: const users = await prisma.user.findMany(); const results = users.map((u) => ({ ...u, fullName: `${u.firstName} ${u.lastName}`, })); ``` ```ts title="TypeORM" import { Entity, Column, VirtualColumn, AfterLoad } from 'typeorm'; @Entity() class User { @Column() firstName: string; @Column() lastName: string; // A subquery inlined into the SELECT, and into WHERE, but not into ORDER BY. @VirtualColumn({ query: (alias) => `SELECT CONCAT(${alias}.firstName, ' ', ${alias}.lastName)`, }) fullName: string; } ``` ```ts title="UQL" // One expression on the entity, inlined into SELECT, WHERE and ORDER BY, or a real // generated column with `stored: true`. It is SQL you write, so portability is yours: // `user.firstName` becomes the alias-qualified column of whichever statement reads it. import { raw } from 'uql-orm'; @Entity() class User { @Field({ type: String }) firstName: string | null; @Field({ type: String }) lastName: string | null; @Field({ type: String, computed: (user) => raw`CONCAT(${user.firstName}, ' ', ${user.lastName})`, }) fullName?: string | null; } ``` MikroORM, TypeORM, and UQL put the expression in the entity, where it filters like a real column with no schema change. TypeORM stops one step short: it inlines the expression in `WHERE` but not in `ORDER BY`, so sorting by a `@VirtualColumn` is a hand-written `addOrderBy`. That is the difference that matters: a value you can only compute after the fetch is a value you cannot query by. --- ## Mutations ```ts title="Drizzle" // You think about what the database returned. import { eq } from 'drizzle-orm'; const [user] = await db.insert(users).values({ name: 'Jane' }).returning(); await db.update(users).set({ name: 'Jane D.' }).where(eq(users.id, user.id)); await db .update(users) .set({ status: 'archived' }) .where(eq(users.status, 'inactive')); await db.delete(users).where(eq(users.id, user.id)); ``` ```ts title="MikroORM" // You think about the flush cycle: nothing is written until it runs. const user = em.create(User, { name: 'Jane' }); await em.flush(); await em.nativeUpdate(User, { id: user.id }, { name: 'Jane D.' }); await em.nativeUpdate(User, { status: 'inactive' }, { status: 'archived' }); await em.nativeDelete(User, { id: user.id }); ``` ```ts title="Prisma" // You think about the row: each call writes when you make it and returns the record. const user = await prisma.user.create({ data: { name: 'Jane' } }); await prisma.user.update({ where: { id: user.id }, data: { name: 'Jane D.' } }); await prisma.user.updateMany({ where: { status: 'inactive' }, data: { status: 'archived' }, }); await prisma.user.delete({ where: { id: user.id } }); ``` ```ts title="TypeORM" // You think about the entity: `save` picks insert or update from whether the id is // set, while `update` skips the entity and goes straight to SQL. const user = manager.create(User, { name: 'Jane' }); await manager.save(user); await manager.update(User, user.id, { name: 'Jane D.' }); await manager.update(User, { status: 'inactive' }, { status: 'archived' }); await manager.delete(User, user.id); ``` ```ts title="UQL" // You think about the statement: the cardinality is in the method name, and an insert // hands back the id rather than the row, so reading it back is a separate call. const id = await pool.insertOne(User, { name: 'Jane' }); await pool.updateOneById(User, id, { name: 'Jane D.' }); await pool.updateMany( User, { $where: { status: 'inactive' } }, { status: 'archived' }, ); await pool.deleteOneById(User, id); ``` Single-row CRUD is the same everywhere. The mental model is not: Drizzle hands you whatever the database returned, MikroORM defers the write to a flush cycle, and Prisma, TypeORM, and UQL just write. **Batch inserts** are where they diverge most. UQL’s [`insertMany`](https://uql-orm.dev/querying/methods.md#insert-ids): - returns an id per row on every database; - accepts records with different column sets in one statement, a missing cell taking the column’s default; - automatically chunks a batch that would exceed the driver’s bind-parameter limit. Those ids are exact wherever the database reports them per row: PostgreSQL, CockroachDB, MariaDB, and SQLite (including LibSQL/Turso, Cloudflare D1, and Bun SQL) via `RETURNING`, MSSQL via `OUTPUT`, and MongoDB via `insertedIds`. MySQL has no `RETURNING`, so ids there are inferred, and only when that inference is sound; otherwise the entry comes back `undefined` rather than wrong. Prisma’s `createMany` returns only a row count (`createManyAndReturn` returns ids, but not on MySQL). Drizzle, MikroORM, and TypeORM use `RETURNING` where the database has it; on MySQL, which does not, they derive the batch’s ids by counting up from the single reported `insertId`, so a batch mixing explicit and auto-generated ids comes back misnumbered. --- ## Soft delete ```ts title="Drizzle" import { eq, isNull } from 'drizzle-orm'; // No built-in soft delete: the column is yours to declare, and yours to filter on // in every query that should not see the deleted rows. export const users = pgTable('users', { id: serial('id').primaryKey(), deletedAt: timestamp('deleted_at'), }); await db.update(users).set({ deletedAt: new Date() }).where(eq(users.id, 1)); const results = await db.select().from(users).where(isNull(users.deletedAt)); ``` ```ts title="MikroORM" import { defineEntity, p } from '@mikro-orm/core'; const UserSchema = defineEntity({ name: 'User', properties: { id: p.integer().primary(), deletedAt: p.datetime().nullable(), }, filters: { softDelete: { name: 'softDelete', cond: { deletedAt: null }, default: true, }, }, }); export class User extends UserSchema.class {} UserSchema.setClass(User); // Queries auto-filter soft-deleted rows; use an onFlush subscriber // to convert em.remove() calls into deletedAt updates. ``` ```ts title="Prisma" // No built-in soft delete either, and the field cannot be declared here: it is // `deletedAt DateTime?` in schema.prisma, then yours to filter on in every query. await prisma.user.update({ where: { id: 1 }, data: { deletedAt: new Date() } }); const results = await prisma.user.findMany({ where: { deletedAt: null } }); ``` ```ts title="TypeORM" @Entity() export class User { @PrimaryGeneratedColumn() id: number; @DeleteDateColumn() deletedAt: Date; } // Native support for soft-deletion and automatic filtering await manager.softDelete(User, id); ``` ```ts title="UQL" @Entity() export class User { @Id({ type: Number }) id: number; @Field({ type: Date, softDelete: true }) deletedAt: Date | null; } // Marking the field enables global soft-deletion behavior await pool.deleteOneById(User, id); // soft delete await pool.restoreOneById(User, id); // bring it back await pool.deleteOneById(User, id, { hardDelete: true }); // remove for good ``` Filtering `deletedAt: null` by hand means one forgotten clause leaks deleted rows. Three of the five filter the read side for you. In UQL this is one use of its general [query filters](https://uql-orm.dev/querying/filters.md), not a special case for deletes. --- ## Filtering: Comparison operators ```ts title="Drizzle" // A function per operator, so the filter cannot be serialized or sent over the wire. import { gte, lte, ilike, notInArray, and } from 'drizzle-orm'; const results = await db .select() .from(users) .where( and( gte(users.age, 18), lte(users.age, 65), ilike(users.name, 'A%'), notInArray(users.status, ['banned', 'inactive']), ), ); ``` ```ts title="MikroORM" // An object, but every operator is offered on every field, so `{ age: { $like } }` // compiles too. `$ilike` is PostgreSQL-only. const results = await em.find(User, { age: { $gte: 18, $lte: 65 }, name: { $ilike: 'A%' }, status: { $nin: ['banned', 'inactive'] }, }); ``` ```ts title="Prisma" // An object too, and the generated client types operators per field, so // `{ age: { startsWith } }` fails to compile. Case-insensitivity is a `mode` flag // instead of an operator, and only PostgreSQL and MongoDB accept it. const results = await prisma.user.findMany({ where: { age: { gte: 18, lte: 65 }, name: { startsWith: 'A', mode: 'insensitive' }, status: { notIn: ['banned', 'inactive'] }, }, }); ``` ```ts title="TypeORM" // Helper functions inside the object, so this one is not serializable either. import { Between, ILike, Not, In } from 'typeorm'; const results = await manager.findBy(User, { age: Between(18, 65), name: ILike('A%'), status: Not(In(['banned', 'inactive'])), }); ``` ```ts title="UQL" // Operators are typed per field: `$like` on strings, `$gt` on comparables, `$size` on // arrays, so `{ age: { $like: '3%' } }` fails to compile. `$istartsWith`/`$iincludes` // emit the right SQL on every dialect rather than PostgreSQL's `ILIKE`. const results = await pool.findMany(User, { $where: { age: { $gte: 18, $lte: 65 }, name: { $istartsWith: 'A' }, status: { $nin: ['banned', 'inactive'] }, }, }); ``` Only the three object filters survive `JSON.stringify`. Of those, MikroORM’s takes any operator on any field, while Prisma’s and UQL’s reject a nonsense one at compile time. --- ## JSON / JSONB operators (practical coverage) | JSON capability | **Drizzle** | **MikroORM** | **Prisma** | **TypeORM** | **UQL** | | - | - | - | - | - | - | | Nested / Dot-notation JSON filtering | ❌¹ | ✅² | ✅³ | 🔌⁴ | ✅ | | Atomic JSON key merge/update | ❌¹ | ❌² | 🔌⁴ | 🔌⁴ | ✅ | | Atomic JSON key removal (`unset`) | ❌¹ | ❌² | ❌ | ❌ | ✅ | | Atomic JSON array append (`push`) | ❌¹ | ❌² | ❌ | ❌ | ✅ | | JSON array query operators (`size`, `all`, `elemMatch`) | ❌¹ | ✅² | 🔌⁴ | 🔌⁴ | ✅ | | Same JSON API on every SQL dialect | ❌ | ✅² | ❌ | ❌ | ✅ | | Declared index on a JSON path | 🔌⁵ | 🔌⁵ | 🔌⁵ | 🔌⁵ | ✅ | ¹ Requires raw SQL. ² MikroORM provides one interface for querying JSON properties (via nested objects and `$elemMatch`) natively across SQL dialects, but relies on full object substitution rather than atomic diffing operators for JSON mutations. ³ Prisma advanced JSON filtering is available on selected connectors and has connector-specific limitations. ⁴ Achievable with dialect-specific SQL expressions or query-builder escape hatches, not a high-level JSON operator API that works the same on every dialect. ⁵ Reachable as a raw expression index, which is a string of that dialect’s own SQL and is not checked against the document’s shape. UQL declares the path (`@Index((user) => [{ column: user.kind, jsonPath: { path: 'theme.color', type: String } }])`), checks it against the field’s type, and emits what the engine has: a path index on PostgreSQL, CockroachDB, SQLite and MySQL, MySQL’s multi-valued index for array containment and element matches, and a refusal on MariaDB rather than DDL it would reject. See [JSON / JSONB](https://uql-orm.dev/querying/json.md) for generated SQL examples and practical baseline dialect versions. --- ## Network boundaries & APIs ```ts title="Drizzle" // The API layer is yours: one hand-written route per model, kept in sync by hand. import { eq } from 'drizzle-orm'; app.get('/api/users', async (req, res) => { const results = await db .select() .from(users) .where(eq(users.status, req.query.status)); res.json(results); }); ``` ```ts title="MikroORM" // The filter is serializable, but it stops at the server: the route is still yours. app.get('/api/users', async (req, res) => { const results = await em.find(User, { status: req.query.status }); res.json(results); }); ``` ```ts title="Prisma" // Same: the query is JSON, but nothing ships it across the wire for you. app.get('/api/users', async (req, res) => { const where = { status: req.query.status }; res.json(await prisma.user.findMany({ where })); }); ``` ```ts title="TypeORM" // Another hand-written bridge. app.get('/api/users', async (req, res) => { const results = await manager.find(User, { where: { id: req.query.id } }); res.json(results); }); ``` ```ts title="UQL" // Backend: auto-generated REST API for your entities import { createFetchHandler } from 'uql-orm/http'; const handler = createFetchHandler({ pool, include: [User] }); // Frontend (Client-side) import { HttpQuerier } from 'uql-orm/browser'; const http = new HttpQuerier('/api'); const { data: results } = await http.findMany(User, { $where: { status: 'active' }, }); ``` A serializable query is only half of it; something still has to carry it. UQL’s [HTTP transport](https://uql-orm.dev/http.md) is framework-agnostic, so the same handler mounts on Hono, Elysia, Next.js, Bun, Deno, Workers, or [Express](https://uql-orm.dev/express.md), and it pairs with a typed [browser client](https://uql-orm.dev/browser.md) that carries transactions and authorization hooks. --- ## Migrations & synchronization ```bash title="Drizzle" # 1. You edit your TS schema # 2. You run a CLI command to generate a JSON "snapshot" # 3. You run another command to generate a SQL migration from that snapshot # 4. Finally, you apply the SQL to your database npx drizzle-kit generate # dialect comes from drizzle.config.ts npx drizzle-kit push ``` ```ts title="MikroORM" // 1. You edit your entities // 2. MikroORM diffs your metadata against the live DB (or a schema dump) // 3. It generates a TS/JS migration file await orm.getMigrator().createMigration(); await orm.getMigrator().up(); ``` ```bash title="Prisma" # 1. You edit the proprietary .prisma file # 2. You run a 'dev' command which requires a "Shadow Database" to diff # 3. Prisma generates a SQL file and applies it npx prisma migrate dev --name add_nickname ``` ```bash title="TypeORM" # 1. You edit your entities # 2. TypeORM can auto-sync in dev (dangerous for production) # 3. Or you manually generate a migration by diffing against a live DB npx typeorm migration:generate -d ./data-source.ts ./migrations/AddNickname ``` ```bash title="UQL" # 1. You edit your entity class # 2. UQL diffs your entity classes directly against the live database # 3. It auto-generates a clean, timestamped DDL migration npx uql-migrate generate:entities add_nickname npx uql-migrate up # Or skip the file and apply the same diff in place (dev databases) npx uql-migrate sync ``` MikroORM and UQL are entity-first: your code is the source of truth, and the diff runs against the live database. Nothing sits in between, neither a DSL of its own (Prisma) nor a JSON snapshot that can drift from both sides (Drizzle). The dev shortcut differs in what it is allowed to do. TypeORM’s `synchronize: true` and Prisma’s `db push` will drop a column or rewrite a type to reach the target schema, and MikroORM’s `schema:update` does the same unless you pass `--safe`. [`uql-migrate sync`](https://uql-orm.dev/migrations.md#syncing-without-a-migration-file) inverts that default: it creates what is missing and refuses the destructive half of the diff unless you pass `--unsafe`. --- ## Streaming ```ts title="Drizzle" // `.iterator()` exists on the MySQL-family sessions only (mysql2, PlanetScale, // TiDB, SingleStore); the PostgreSQL and SQLite drivers have no equivalent. const stream = await db.select().from(users).iterator(); for await (const user of stream) { await writeToCsv(user); } ``` ```ts title="MikroORM" // AsyncIterable on every driver. On MongoDB, `populate` throws rather than being ignored. const stream = await em.stream(User, { status: 'active' }); for await (const user of stream) { await writeToCsv(user); } ``` ```ts title="Prisma" // No streaming API at all, so you drive cursor pagination yourself and carry the cursor: let cursor: number | undefined; while (true) { const batch = await prisma.user.findMany({ take: 100, skip: cursor ? 1 : 0, cursor: cursor ? { id: cursor } : undefined, }); if (batch.length === 0) break; for (const user of batch) await writeToCsv(user); cursor = batch[batch.length - 1].id; } ``` ```ts title="TypeORM" // Node stream of raw, un-hydrated rows; on PostgreSQL it also // requires the extra `pg-query-stream` package. const results = await manager.createQueryBuilder(User, 'user').stream(); for await (const row of results) { await writeToCsv(row); } ``` ```ts title="UQL" // AsyncIterable on every driver, hydrated: a plain for-await loop, no event handlers. const results = await pool.findManyStream(User, { $where: { status: 'active' }, }); for await (const user of results) { await writeToCsv(user); } ``` Millions of rows need a real cursor to keep memory flat. Only two of the five give you one on every driver. --- ## Feature matrix Features marked as: ✅ native, 🔌 via extension/plugin, ❌ not available. | Capability | **Drizzle** | **MikroORM** | **Prisma** | **TypeORM** | **UQL** | | - | - | - | - | - | - | | Serializable queries (JSON) | ❌ | ✅ | ✅ | ❌¹¹ | ✅ | | Native semantic search | ✅¹ | 🔌⁶ | 🔌⁶ | 🔌⁶ | ✅ | | Native full-text search, ranked (see [Full-text search](#full-text-search)) | ❌ | ✅ | ✅ | ❌ | ✅ | | Computed fields | ✅⁷ | ✅ | 🔌⁸ | ✅ | ✅ | | No custom DSL needed | ✅ | ✅ | ❌ | ✅ | ✅ | | No codegen needed | ✅ | ✅ | ❌ | ✅ | ✅ | | Deep relation operators | ❌ | ✅ | ✅ | ❌ | ✅ | | Cursor streaming (AsyncIterable) | ✅⁹ | ✅ | ❌ | 🔌¹⁰ | ✅ | | Soft delete (built-in) + restore | ❌ | 🔌 | 🔌 | ✅⁴ | ✅ | | Global query filters (scopes) | ❌ | ✅ | 🔌 | ❌ | ✅ | | Multi-tenancy / RLS | ❌⁵ | ✅⁵ | 🔌⁵ | ❌ | ✅⁵ | | Row-level locking (`FOR UPDATE`, `SKIP LOCKED`) | ✅ | ✅ | ❌¹⁴ | ✅ | ✅ | | Optimistic locking (version column) | ❌¹⁵ | ✅ | ❌¹⁵ | ✅¹⁵ | ✅ | | Multiple schemas | ✅ | ✅ | ✅ | ✅ | ✅ | | Lifecycle hooks | ❌ | ✅ | 🔌 | ✅ | ✅ | | Works without an active ORM context (no Unit of Work / flush cycle) | ✅ | ❌ | ✅ | ✅ | ✅ | | Auto REST API | ❌ | ❌ | ❌ | ❌ | ✅ | | Browser querier | ❌ | ❌ | ❌ | ❌ | ✅ | | CommonJS (`require`) | ✅ | ❌¹² | ✅ | ✅ | ❌¹² | | Data browser / GUI | ✅¹³ | ❌ | ✅¹³ | ❌ | ❌ | | MongoDB support | ❌ | ✅ | ✅ | ✅ | ✅ | | One query mental model across SQL + MongoDB | ❌ | ✅ | 🔌² | ❌³ | ✅ | ¹ PostgreSQL (pgvector) only. ² Prisma Client is broadly consistent across SQL/Mongo, but connector-specific capabilities and raw-query APIs diverge. ³ TypeORM’s Mongo path diverges from SQL behavior (for example, QueryBuilder support differs). ⁴ TypeORM soft-deletes and restores natively (`@DeleteDateColumn` + `restore()`); MikroORM’s filters hide the rows but the delete-to-timestamp conversion is yours to write, and Prisma needs a client extension. ⁵ This row is about ORM-level tenant scoping. UQL’s `security` filters are non-bypassable and fail closed when the request context is missing; MikroORM has native tenant filters, but any query can disable them (`filters: false`); Prisma relies on client extensions (bypassable). All three are app-layer, so raw SQL escapes them. Database-native row-level security is the complementary backstop, enforced regardless of app code: Drizzle declares Postgres RLS policies in the schema (`pgPolicy` / `crudPolicy`), and Prisma documents driving them via an extension. Strongest isolation combines both. ⁶ See the [Semantic search](#semantic-search) table for what each 🔌 is: MikroORM has `pgvector`’s own adapter, TypeORM a vector column type with hand-written distance SQL, Prisma raw SQL or a community client extension. MikroORM’s and Prisma’s are pgvector, so PostgreSQL-only; TypeORM’s column maps on more engines, but with no distance operator on any of them. ⁷ Either restated per query as a `sql` expression, or declared once as a database generated column (`generatedAlwaysAs`), which is a real column and so needs a migration. ⁸ A `$extends({ result: ... })` client extension centralizes the mapping, but computes it after the fetch, so it can’t be filtered or sorted in the database. ⁹ MySQL-family drivers only (mysql2, PlanetScale, TiDB, SingleStore); `.iterator()` does not exist on the PostgreSQL or SQLite sessions. ¹⁰ `stream()` returns a Node stream of raw, un-hydrated rows, and needs the extra `pg-query-stream` package on PostgreSQL. ¹¹ Partly. TypeORM’s `find()` options are a plain object, so an equality filter does serialize; anything past that (`Between`, `ILike`, `Not`, `In`) is a function call, and the QueryBuilder is not an object at all. ¹² UQL is ESM-only, so there is no `require()` path and it needs Node 24 or newer. MikroORM v7 is ESM-only too (`@mikro-orm/core` ships no `require` condition). If you are on CommonJS, that rules both of us out. ¹³ Drizzle Studio and Prisma Studio. UQL has no GUI: you browse your data with your database’s own tooling. ¹⁴ Prisma has no row-lock option, so a work queue there is `$queryRaw`. The other four take one on a read: Drizzle’s `.for('update', { skipLocked: true })`, MikroORM’s `lockMode: LockMode.PESSIMISTIC_PARTIAL_WRITE`, TypeORM’s `setLock('pessimistic_write').setOnLocked('skip_locked')`, and UQL’s [`$lock`](https://uql-orm.dev/querying/locking.md) (`{ $wait: 'skip' | 'nowait' }`). All of them need an open transaction, and none of them is on SQLite. ¹⁵ Drizzle and Prisma have no version column: the check is a `where` you write and a row count you read. TypeORM’s `@VersionColumn` increments on save, and its `OptimisticLockVersionMismatchError` comes from a read with `lock: { mode: 'optimistic', version }`. MikroORM’s `@Property({ version: true })` is checked when the unit of work flushes. UQL matches the version in the `UPDATE` itself, on every engine. --- ## Database support | Database | **Drizzle** | **MikroORM** | **Prisma** | **TypeORM** | **UQL** | | - | - | - | - | - | - | | Cloudflare D1 | ✅ | 🔌¹ | ✅ | ❌ | ✅ | | CockroachDB | ✅ | ✅ | ✅ | ✅ | ✅ | | LibSQL / Turso | ✅ | ✅ | ✅ | ❌ | ✅ | | MariaDB | ✅ | ✅ | ✅ | ✅ | ✅ | | MongoDB | ❌ | ✅ | ✅ | ✅ | ✅ | | MSSQL | ✅² | ✅ | ✅ | ✅ | ✅ | | MySQL | ✅ | ✅ | ✅ | ✅ | ✅ | | Neon Serverless | ✅ | 🔌³ | ✅ | 🔌³ | ✅ | | Oracle | ❌ | ✅ | ❌ | ✅ | ❌ | | PGlite | ✅ | ✅ | 🔌⁴ | 🔌⁴ | ✅ | | PostgreSQL | ✅ | ✅ | ✅ | ✅ | ✅ | | SQLite | ✅ | ✅ | ✅ | ✅ | ✅ | ¹ MikroORM Cloudflare D1 support is currently documented as an experimental path through its SQL query builder. ² Drizzle MSSQL support shipped in the v1.0 beta line (not yet in a stable release). ³ No dedicated adapter: you hand the pg-compatible `@neondatabase/serverless` pool to the PostgreSQL driver yourself (MikroORM’s `driverOptions`, TypeORM’s `driver` option). Drizzle, Prisma and UQL each ship a Neon entry point. ⁴ Only through a third-party package (`pglite-prisma-adapter`, `typeorm-pglite`). Drizzle, MikroORM and UQL each publish the entry point themselves. --- ## Install footprint What each one puts on disk before you add a driver, measured on a fresh install of the package alone, September 2026. | | **Drizzle** | **MikroORM** | **Prisma** | **TypeORM** | **UQL** | | - | - | - | - | - | - | | Installed | 9.9 MB | 4.8 MB | 71.2 MB | 22.5 MB | **1.5 MB** | | Files | 2,667 | 1,154 | 94 | 3,663 | **425** | Packages measured: `drizzle-orm` 0.45.2, `@mikro-orm/postgresql` 7.2.0, `@prisma/client` 7.10.0, `typeorm` 1.1.1, `uql-orm` 0.48.0. Every row is the ORM on its own: add `pg` to any of them and they all grow by the same amount. 92% of `@prisma/client` is one thing: query compilers cross-compiled to WebAssembly, one per engine it supports, in both `fast` and `small` builds, CommonJS and ESM. Prisma 7 is “Rust-free” in that no Rust binary runs at query time and the client runtime is TypeScript, but the compiler inside those WASM modules is still Rust. Shipping every engine is the same call UQL makes with dialects, so the headline number alone is not fair to hold against it. Per dialect is the comparison that holds: Prisma’s PostgreSQL compiler is 4.4 MB in the `fast` build, against 27.1 kB gzipped for the whole of `uql-orm/postgres`. UQL installs one package with no runtime dependencies and every dialect included. The pure `fetch` drivers (Turso Cloud, Neon, Cloudflare D1) stay that way end to end, so an edge bundle pulls in no native binaries. The costs are real and worth stating: ESM-only, so there is no `require()` path, and Node 24 or newer. CI budgets the per-entry sizes and fails the build when they regress. [Zero Dependencies: what we deleted to fit on the edge](https://uql-orm.dev/blog/zero-dependencies.md)The full table, how it was measured, and what came out to get there. --- ## Benchmark Our [open benchmark](https://uql-orm.dev/benchmark.md) times a full PostgreSQL lifecycle per entry and measures what each ORM adds over hand-written driver code. **UQL takes first place** on both drivers, and on Bun SQL adds the least of any entry; the next ORM adds 1.5x as much and the slowest 6x. The same harness weighs the heap around each step: UQL allocates [73KB per lifecycle](https://uql-orm.dev/blog/measuring-orm-memory.md) above hand-written `pg`, the next ORM 524KB and the heaviest 3.6MB. It also compiles eleven ordinary mistakes against each tool’s own API, and UQL is the only one that catches all eleven. [Full Benchmark Results](https://uql-orm.dev/benchmark.md)Complete result tables, methodology, interactive charts, and reproduction instructions. [Rename Safety](https://uql-orm.dev/rename-safety.md)Six ORMs renaming a field, a foreign key and a relation: which indexes, checks, relations, queries and raw SQL follow. --- # ORM Benchmark > What each TypeScript ORM costs per request against a real PostgreSQL: Drizzle vs MikroORM vs Prisma vs Sequelize vs TypeORM vs UQL. Source: https://uql-orm.dev/benchmark Our [open-source benchmark](https://github.com/rogerpadilla/ts-orm-benchmark) puts each ORM through a full PostgreSQL lifecycle: insert, read, update, read, nested read, delete, read. Here is the latest run. > PostgreSQL 18.6 (Homebrew), Bun 1.4.2, Apple M4 Max, September 2026. Median µs per operation over 250 rounds, after 125 warmup rounds, interleaved and rotated. Every median is ±2.4% or tighter at 95% confidence. *Versions: [Drizzle](https://orm.drizzle.team) 0.45.2 · [MikroORM](https://mikro-orm.io) 7.2.0 · [Prisma](https://www.prisma.io) 7.10.0 · [Sequelize](https://sequelize.org) 6.37.8 · [TypeORM](https://typeorm.io) 1.1.1 · [UQL](https://uql-orm.dev) 0.68.1.* ## Results **Adds** is what the ORM itself costs, everything on top of what the driver would have spent anyway. The two `ref` rows are the floors: hand-written SQL with the rows mapped by hand, which is what an ORM has to earn its keep against. Bun SQL entries are measured against `bun sql` and the rest against `raw pg`, so a fast driver is never counted as the ORM’s doing. | # | Entry | Adds µs | Total µs | | - | - | - | - | | ref | *bun sql* | floor | 1138 | | ref | *raw pg* | floor | 1181 | | 1 | **UQL (bunSql)** | +247 | 1385 | | 1 | **UQL** | +275 | 1456 | | 3 | Drizzle (bunSql) | +385 | 1523 | | 3 | Drizzle | +419 | 1600 | | 5 | TypeORM | +544 | 1725 | | 6 | Sequelize | +875 | 2056 | | 6 | Prisma | +929 | 2110 | | 8 | MikroORM | +1571 | 2752 | Entries share a place when their confidence intervals overlap: an equal number means a difference this run cannot resolve, not a tie broken in someone’s favour. Totals only span 2.0x, because every entry pays the same database cost. What the ORM itself adds spans 6x: 247µs for UQL (bunSql), 1571µs for MikroORM. Each entry is measured against its own driver’s floor, so a faster driver is never counted as the ORM’s win. Running the same UQL code on Bun SQL instead of `pg` saves 71µs, but only 28µs of that is UQL: the other 43µs is the gap between the two floors, free to anything on that driver. ### Per step The three steps where how much data is bound and hydrated decides the number. The [interactive charts](https://rogerpadilla.github.io/ts-orm-benchmark/chart.html) break down every step. | Entry (µs) | insert | read | nested | Total, 7 steps | | - | - | - | - | - | | *[bun sql](https://bun.sh/docs/api/sql)* | 357 | 181 | 194 | 1138 | | *[raw pg](https://node-postgres.com)* | 346 | 206 | 219 | 1181 | | [UQL (bunSql)](https://uql-orm.dev) | **376** | **224** | **318** | **1385** | | [UQL](https://uql-orm.dev) | 380 | 259 | 352 | 1456 | | [Drizzle (bunSql)](https://orm.drizzle.team) | 427 | 241 | 362 | 1523 | | [Drizzle](https://orm.drizzle.team) | 433 | 267 | 408 | 1600 | | [TypeORM](https://typeorm.io) | 485 | 340 | 344 | 1725 | | [Sequelize](https://sequelize.org) | 485 | 396 | 546 | 2056 | | [Prisma](https://www.prisma.io) | 892 | 287 | 396 | 2110 | | [MikroORM](https://mikro-orm.io) | 463 | 744 | 882 | 2752 | Columns: insert is INSERT 10 rows, returning ids; read is SELECT with WHERE, SORT, LIMIT 200; nested is SELECT 50 parents with their children. The biggest gap is Prisma’s insert: 892µs against 376-485µs for everyone else. The other 4 steps are asserted every round but not published: they are round trips with almost nothing in them, worth 465-663µs of each total and separating the field by at most 110µs. ### Three runtimes The same lifecycle runs on Bun, Node and Deno, from one bundle built by Bun so the runtime is the only variable. The Bun SQL rows sit out here, since that client is a Bun API. > Bun 1.4.2, Node 24.20.0, Deno 2.9.6, all running the same bundled JavaScript, one at a time against the same database. PostgreSQL 18.6 (Homebrew), Apple M4 Max, September 2026. µs for a whole lifecycle, nearest-rank percentiles over 2000 rounds after 250 warmup, so a p99 is drawn from the 21 slowest rounds. | Entry (µs) | Bun p50 | Bun p99 | Node p50 | Node p99 | Deno p50 | Deno p99 | | - | - | - | - | - | - | - | | [raw pg](https://node-postgres.com) | **1183** | 3940 | 1250 | **3765** | 1276 | 3860 | | [UQL](https://uql-orm.dev) | **1486** | 5178 | 1553 | **3766** | 1540 | 4588 | | [Drizzle](https://orm.drizzle.team) | **1612** | 6217 | 1804 | 4803 | 1808 | **4771** | | [TypeORM](https://typeorm.io) | **1661** | 6180 | 1853 | **4643** | 1750 | 4772 | | [Sequelize](https://sequelize.org) | **1966** | 7426 | 2202 | 5763 | 2214 | **5457** | | [Prisma](https://www.prisma.io) | **2073** | 8038 | 2325 | **6224** | 2488 | 6487 | | [MikroORM](https://mikro-orm.io) | **2712** | 11011 | 3785 | 9103 | 3787 | **8483** | On `raw pg`, the same code on all of them, the runtimes are 93µs apart at p50 but 175µs apart at p99: Bun leads the median, Node the tail, and each p99 is 233% on Bun, 201% on Node, 203% on Deno above its own p50. Switching runtime moves any single entry by at most 1075µs at p50 (MikroORM), where switching ORM on one runtime moves it 1191-2223µs. Both are differences of measured medians, known to ±38µs and ±39µs, so read them as ranges rather than as a ranking. The one pair that changes places between runtimes is Drizzle and TypeORM, 41µs apart. ### Memory The same lifecycle weighed for what it allocates, on Node, one process per entry. **Adds KB** is the heap above the hand-written floor, per request. > PostgreSQL 18.6 (Homebrew), Node 24.20.0, Apple M4 Max, September 2026. Median KB allocated per step over 60 rounds after 60 warmup of a 7-step lifecycle. Rounds a garbage collection ran in are discarded, never corrected, and no entry lost more than 1% of its own (MikroORM). | Entry | insert | read | nested | Total KB | Adds KB | | - | - | - | - | - | - | | *[raw pg](https://node-postgres.com)* | 14 | 87 | 106 | 245 | floor | | [UQL](https://uql-orm.dev) | 44 | 105 | 82 | 320 | **+75** | | [Drizzle](https://orm.drizzle.team) | 134 | 248 | 238 | 751 | +506 | | [Prisma](https://www.prisma.io) | 273 | 220 | 374 | 1055 | +810 | | [TypeORM](https://typeorm.io) | 126 | 295 | 503 | 1066 | +821 | | [Sequelize](https://sequelize.org) | 100 | 425 | 592 | 1295 | +1050 | | [MikroORM](https://mikro-orm.io) | 77 | 1470 | 2071 | 3877 | +3632 | Above the floor the field spans 48.4x: 75KB for UQL, 3632KB for MikroORM, and nested opens it widest: MikroORM’s 2071KB against UQL’s 82KB. Almost none of it survives: another 60 lifecycles, collected either side, leave at most 31KB behind (Sequelize), identity maps included. What the table prices is collector pressure, not a resident set that grows. ## Type safety The same benchmark writes ordinary mistakes in each tool’s own API (a misspelled column in a projection, a filter on a column that is not there, a text operator on a number, a sum over a text column, a read of a column the projection left out) and reports which ones the compiler refuses. Each file is compiled twice, once as written and once with every mistake corrected, so a green mark means the mistake errored *and* the correction was clean. > Checked with TypeScript 7.0.2, 11 probes per entry. | Mistake | [Drizzle](https://orm.drizzle.team) | [MikroORM](https://mikro-orm.io) | [Prisma](https://www.prisma.io) | [Sequelize](https://sequelize.org) | [TypeORM](https://typeorm.io) | [UQL](https://uql-orm.dev) | | - | - | - | - | - | - | - | | Misspelled column in the projection | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | | Misspelled column in the filter | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | String value against a numeric column | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | | Text operator against a numeric column | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | | Misspelled column in the sort | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | | Sum over a text column | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ | | Misspelled column inside a loaded relation | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | | Misspelled column in inserted data | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Number written into a text column | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Reading a column the projection left out | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | | Reading a misspelled column off a loaded relation | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | **Caught**, of 11 | 9 | 9 | 10 | 5 | 10 | **11** | UQL catches 11 of the 11, Sequelize 5. Every mistake here is caught by at least one entry. The corrected copy of every file compiles clean, which is what makes a red mark a missing check rather than a broken query. The columns are alphabetical, not ranked: a handful of probes cannot separate these tools the way a microsecond can, and ties are common. The queries behind each mark are [in the benchmark](https://github.com/rogerpadilla/ts-orm-benchmark/tree/main/type-safety). ## Why UQL is fast - Schema metadata is worked out once at startup, so nothing is looked up while a query runs. - SQL is written straight into a string buffer, with no intermediate builder objects. ## Method - Everyone defines the same `Company` and `User`, runs the same seven steps through its own idiomatic API, and gets one connection with no pooling. Entries are interleaved and rotated, so none of them keeps a favourable position. - Every step asserts on the rows it returns, so a step that silently does nothing fails instead of scoring well. - Medians, never means, so one GC pause cannot dominate a number, and each carries the 95% confidence interval the caption above states. Places are assigned over those intervals, so entries the run cannot separate share one instead of being ordered by noise. - The runtime figures come from one Bun-built bundle run on each runtime in turn, so no runtime is charged for its own TypeScript loader. The memory figures come from a process per entry, since a shared heap cannot be attributed. - The type-safety probes are compiled with TypeScript 7.0.2 at `strict`, against the same entity definitions the timed lifecycle queries: one definition per ORM, scored and measured, so neither half is judged on a model the other never used. ```bash git clone https://github.com/rogerpadilla/ts-orm-benchmark.git cd ts-orm-benchmark bun install DATABASE_URL=postgres:///postgres bun run bench DATABASE_URL=postgres:///postgres bun run bench.runtimes DATABASE_URL=postgres:///postgres bun run bench.memory bun run bench.types ``` --- Speed is only one axis. For how the APIs and features line up, see the [comparison](https://uql-orm.dev/comparison.md). [Comparison](https://uql-orm.dev/comparison.md)Drizzle vs MikroORM vs Prisma vs TypeORM vs UQL, with actual code for every common operation. --- # ORM Type Safety Comparison > Ordinary mistakes written in six TypeScript ORMs, compiled live: Drizzle vs MikroORM vs Prisma vs Sequelize vs TypeORM vs UQL. Source: https://uql-orm.dev/type-safety This is what a mistake or typo costs you per ORM: ordinary mistakes (a misspelled key, a text operator on a number, a sum over a text column, a read of a column you did not select) written in each tool’s own API against the same two entities. The editor below runs the *real TS compiler* over the *real packages* you would install. Switch tabs to see what each ORM catches. ```ts import { asc, eq, gt, like, sum } from 'drizzle-orm'; import { drizzleUsers } from '../src/schema'; import { clients } from './clients'; const { drizzleDb: db } = clients; // Misspelled column in the projection | emial -> email await db .select({ id: drizzleUsers.id, email: drizzleUsers.emial }) .from(drizzleUsers); // Misspelled column in the filter | createdat -> createdAt await db .select({ id: drizzleUsers.id }) .from(drizzleUsers) .where(gt(drizzleUsers.createdat, 0)); // String value against a numeric column | 'one' -> 1 await db .select({ id: drizzleUsers.id }) .from(drizzleUsers) .where(eq(drizzleUsers.createdAt, 'one')); // Text operator against a numeric column | like(drizzleUsers.createdAt, 'abc') -> gt(drizzleUsers.createdAt, 1) await db .select({ id: drizzleUsers.id }) .from(drizzleUsers) .where(like(drizzleUsers.createdAt, 'abc')); // Misspelled column in the sort | idd -> id await db .select({ id: drizzleUsers.id }) .from(drizzleUsers) .orderBy(asc(drizzleUsers.idd)); // Sum over a text column | name -> createdAt await db.select({ total: sum(drizzleUsers.name) }).from(drizzleUsers); // Misspelled column inside a loaded relation | nmae -> name await db.query.Company.findMany({ columns: { id: true }, with: { users: { columns: { nmae: true } } }, }); // Misspelled column in inserted data | emails -> email await db .insert(drizzleUsers) .values([{ name: 'New User', emails: 'new@example.com' }]); // Number written into a text column | 42 -> 'Updated Name' await db.update(drizzleUsers).set({ name: 42 }).where(eq(drizzleUsers.id, 1)); // Reading a column the projection left out | user.email -> user.name const [user] = await db .select({ id: drizzleUsers.id, name: drizzleUsers.name }) .from(drizzleUsers); export const unselected = user.email; // Reading a misspelled column off a loaded relation | .nmae -> .name const [company] = await db.query.Company.findMany({ columns: { id: true }, with: { users: { columns: { id: true, name: true } } }, }); export const nested = company.users[0].nmae; ``` type-safety/drizzle.ts · static preview ## Where the marks come from The files above come from [ts-orm-benchmark](https://github.com/rogerpadilla/ts-orm-benchmark), the repository behind the [speed benchmark](https://uql-orm.dev/benchmark.md), and this site changes nothing in them. Each tool’s queries live in a file of its own there and are compiled twice: once as written, once with every mistake corrected. A mistake counts as caught only when it errors **and** the corrected copy is clean, so nothing scores a point for being broken in a way that has nothing to do with the probe. That is the only place anything is scored, with TypeScript 7.0.2. This site vendors those files with the verdicts they produced and compiles them again at build with TypeScript 6.0.3 (this repository’s own compiler, not the 5.9 Monaco vendors inside the editor above), failing to deploy if that run would disagree. ## What it does not say A handful of probes cannot separate these tools the way a microsecond can. UQL catches all eleven and two others catch ten, which is one probe of daylight and not a gap you should choose a tool over; the honest reading is that five of these six check almost everything an ordinary mistake can be, and one does not. Nothing here covers migrations, tooling, how legible an error is when it does fire, or whether the types stay fast on a schema with two hundred tables. It is one axis, picked because it is the one people argue about without evidence. Each entry is written in the API its own package offers: `@mikro-orm/core` exports no entity decorators, since MikroORM 7 moved them to a separate `@mikro-orm/decorators` package, and TypeORM’s are still the legacy kind, so its entities use `EntitySchema`, which scores the same either way. Drizzle’s flat probes are written on `db.select()`, the builder its timed read uses, and its relation probes on `db.query.*`, the only API it has for one: each half is scored on the same code the benchmark times. I wrote UQL, so take the verdict for what it is worth from its author: the [method](https://github.com/rogerpadilla/ts-orm-benchmark#type-safety) is in the open, the probes are the same for every entry, and the editor above is running the compiler rather than showing you my screenshot of it. [Rename Safety](https://uql-orm.dev/rename-safety.md)Six ORMs renaming a field, a foreign key and a relation: which indexes, checks, relations, queries and raw SQL follow. [Benchmark](https://uql-orm.dev/benchmark.md)The other half: what each ORM costs on a full PostgreSQL round trip, on Bun, Node and Deno. [Comparison](https://uql-orm.dev/comparison.md)Drizzle vs MikroORM vs Prisma vs TypeORM vs UQL, with actual code for every common operation. --- # What survives a rename? Six TypeScript ORMs > Six ORMs renamed the way your editor renames, and compiled live: which mentions follow, which the compiler catches, and which break silently. Source: https://uql-orm.dev/rename-safety A rename is a refactor nobody thinks twice about because it is often pretty simple: just press F2, enter the new name and trust that every place is updated. An ORM’s rename reaches only as far as it links its schema to the code. Each tab below is one ORM’s model and 19 places that name three of its members, renamed the way F2 renames: `emailAddress` to `email`, `employerId` to `workplaceId`, `employer` to `workplace`. Green is what the rename edited, red what it left behind for the compiler to stop, and amber what it left behind that still compiles. - renamed - stopped by the compiler - left behind silently ```ts import { count, eq, relations, type SQL, sql } from 'drizzle-orm'; import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { check, index, integer, pgTable, serial, text, uniqueIndex, } from 'drizzle-orm/pg-core'; export const companies = pgTable('Company', { id: serial().primaryKey() }); export const users = pgTable( 'User', { id: serial().primaryKey(), emailAddress: text().notNull(), employerId: integer().references(() => companies.id), // Generated column emailLower: text().generatedAlwaysAs( (): SQL => sql`lower(${users.emailAddress})`, ), }, (t) => [ // Index on the field index().on(t.emailAddress), // Composite unique index uniqueIndex().on(t.emailAddress, t.employerId), // Covering index column | n/a: Drizzle's index builder has no INCLUDE // Expression index index().on(sql`lower(${t.emailAddress})`), // Partial index condition index() .on(t.employerId) .where(sql`${t.emailAddress} <> ''`), // Check constraint check('address_present', sql`${t.emailAddress} <> ''`), ], ); export const usersRelations = relations(users, ({ one }) => ({ // Foreign key of a relation employer: one(companies, { fields: [users.employerId], references: [companies.id], }), })); export const companiesRelations = relations(companies, ({ many }) => ({ // Inverse side of a relation staff: many(users), })); const schema = { companies, users, usersRelations, companiesRelations }; declare const db: NodePgDatabase; // Field in the projection await db.select({ id: users.id, address: users.emailAddress }).from(users); // Field in the filter await db.select().from(users).where(eq(users.emailAddress, 'ada@example.com')); // Field in the sort await db.select().from(users).orderBy(users.emailAddress); // Field inside a loaded relation await db.query.companies.findMany({ with: { staff: { columns: { emailAddress: true } } }, }); // Relation loaded by name await db.query.users.findMany({ with: { employer: true } }); // Field in inserted data await db.insert(users).values({ emailAddress: 'ada@example.com' }); // Field in updated data await db .update(users) .set({ emailAddress: 'ada@example.com' }) .where(eq(users.id, 1)); // Foreign key in a grouped count await db .select({ company: users.employerId, total: count() }) .from(users) .groupBy(users.employerId); // Field read off the result const [user] = await db.select().from(users); export const address = user.emailAddress; // Raw SQL in a filter await db .select() .from(users) .where(sql`lower(${users.emailAddress}) = ${'ada@example.com'}`); ``` rename-safety/drizzle.ts · static preview UQL follows all 19: its indexes, checks and relations name members through callbacks, and every query key is typed off the entity. Drizzle leaves none behind silently, but 4 for the compiler to stop, where its writes and loaded relations are checked against the table without being linked to it. The other four leave some behind with no error at all: 12 in Sequelize, whose indexes, associations and query options name members as plain strings, 5 in TypeORM, 3 in MikroORM and 3 in Prisma, each breaking only when the code runs or the schema migrates. ## Where this comes from Every file, rename and mark is [ts-orm-benchmark](https://github.com/rogerpadilla/ts-orm-benchmark#rename-safety)’s. Each rename is the one the ORM’s own language server made there, TypeScript 7.0.2’s or, for Prisma’s schema, prisma-language-server 31.12.10’s, replayed here in your browser, where the compiler this site builds with answers it live. The build fails if F2 in this editor would edit any other place, or if a red line would move. Prisma renames its schema and never touches its queries, so its tab renames `schema.prisma`, regenerates the client, and shows the queries compiled against it, as the benchmark does. TypeORM’s decorators are the legacy kind, so the benchmark compiles its file in a project of its own and the editor checks it in a worker of its own. I wrote UQL, so take this for what it is worth from its author: the benchmark is in the open, and every tab above runs the same compiler. [Type Safety](https://uql-orm.dev/type-safety.md)Six ORMs against ordinary mistakes: a misspelled key, a text operator on a number, a column you did not select. [Benchmark](https://uql-orm.dev/benchmark.md)What each ORM costs on a full PostgreSQL round trip, on Bun, Node and Deno. # Decorators > Define entities with the @Entity, @Id, and @Field decorators, and choose column types. Source: https://uql-orm.dev/entities/basic An entity is a plain TypeScript class; its decorators carry the metadata UQL uses for type-safe querying and DDL generation. UQL uses the standard/modern TC39 decorators. Every field states its `type`, checked against the property it annotates: `@Field({ type: String })` on a `number` is a compile error. For plain classes, the [imperative API](https://uql-orm.dev/entities/imperative.md) (`defineEntity`) registers the same metadata from the same options, checked the same way bar one foreign-key case. ## Core Decorators | Decorator | Purpose | | - | - | | `@Entity()` | Marks a class as a database table/collection. Takes `name` for a custom table name and `schema` for the namespace it lives in ([multiple schemas](https://uql-orm.dev/multiple-schemas.md)). | | `@Id({ type })` | Defines the primary key, with support for `onInsert` generators (UUIDs, etc). Declared twice, the key is [composite](#composite-primary-keys). | | `@Field({ type })` | Standard column. `type` is required, except on a foreign key declared with `{ references: () => Entity }`, which inherits the target key’s type. | | `@Index()` | Defines a composite or customized index on one or more columns. | | `@Filter()` | Defines a named [query filter](https://uql-orm.dev/querying/filters.md) (default-on `$where`) for soft-delete, tenancy, or RLS. | | `@OneToOne` | Defines a one-to-one relationship. | | `@OneToMany` | Defines a one-to-many relationship. | | `@ManyToOne` | Defines a many-to-one relationship. | | `@ManyToMany` | Defines a many-to-many relationship. | ```ts import { v7 as uuidv7 } from 'uuid'; import { Entity, Id, Field } from 'uql-orm'; @Entity() export class User { @Id({ type: 'uuid', onInsert: uuidv7, }) id?: string; @Field({ type: String, index: true }) name?: string | null; @Field({ type: String, unique: true, comment: 'User login email', }) email?: string | null; @Field({ type: 'text' }) bio?: string | null; } ``` ### If your build minifies, name the table Without `name`, the table is the class name, which minifiers rewrite: `User` becomes `b`, and changes again as the bundle does. Bun’s `--keep-names` does not currently preserve it. Pass `@Entity({ name: 'user' })` on anything that ships minified. Nothing else is affected, since metadata is keyed on the class and relations resolve through `() => Entity`. `drift:check` catches a mismatch. ## Type Abstraction A column type is stated two ways. `type` is logical and database-agnostic, mapped to each dialect’s own SQL type; prefer it. `columnType` is the SQL type itself, for when you need exact control: ```ts import type { Json } from 'uql-orm'; // Recommended: use `type` for semantic, cross-database types @Field({ type: 'uuid' }) externalId?: string | null; @Field({ type: 'jsonb' }) metadata?: Json<{ theme?: string; priority?: number }> | null; @Field({ type: 'text' }) bio?: string | null; // Use sparingly: `columnType` picks the exact SQL type. `type` is still required, // since it is what the compiler checks the property against. @Field({ type: Number, columnType: 'decimal', precision: 10, scale: 2 }) price?: number | null; @Field({ type: String, columnType: 'varchar', length: 500 }) longBio?: string | null; ``` `type: 'uuid'` generates `UUID` on Postgres and `CHAR(36)` on MySQL, so the same entity migrates to either. Wrap a JSONB field’s type in `Json` so it counts as a field, not a relation. That makes it usable in `$where`, `$select` and `$sort`, with autocompletion for [dot-notation paths](https://uql-orm.dev/querying/comparison-operators.md#jsonb-dot-notation-operators). **Wide integers.** `type: Number` is a `BIGINT` column, and every driver reads it back as a JS number up to 2^53 and as exact text past that, never as a silently rounded number. Declare `type: BigInt` for a typed exact integer: a `bigint` is written exactly on every driver, D1 included, which takes its text. The SQLite drivers are the exception, since none hands over the digits: better-sqlite3, `bun:sqlite` and embedded Turso round, `node:sqlite` and libSQL refuse the value. ## Field Options `@Field` and `@Id` take these options, used for both query validation and schema generation: | Option | Type | Description | | - | - | - | | `name` | `string` | Custom database column name. | | `type` | `Type \| string` | Logical type: `String`, `Number`, `Boolean`, `Date`, `BigInt`, or strings like `'uuid'`, `'text'`, `'json'`, `'jsonb'`, `'timestamp'`, `'timestamptz'`, `'vector'`, `'halfvec'`, `'sparsevec'`. | | `columnType` | `ColumnType` | Explicit SQL column type (e.g., `varchar`, `text`, `jsonb`, `vector`, `halfvec`, `sparsevec`). Takes highest priority. | | `length` | `number` | Column length. If unspecified, defaults to `TEXT` (Postgres/SQLite) or `VARCHAR(255)` (MySQL/Maria). | | `precision` | `number` | Numeric precision, e.g. for `decimal` columns. | | `scale` | `number` | Numeric scale, e.g. for `decimal` columns. | | `nullable` | `boolean` | Whether the column allows NULL values. Defaults to `true`. | | `unique` | `boolean` | Adds a UNIQUE constraint. | | `enum` | `readonly (string \| number)[]` | The values the column accepts, as a `CHECK (col IN (...))`. Needs `as const`; see [Enum + Checks](https://uql-orm.dev/entities/enum-checks.md#enum-fields). | | `index` | `boolean \| string` | Adds an index. Pass a string to name it. | | `defaultValue` | the field’s own type | Default value at the database level. A JSON column takes the SQL literal it stores, e.g. `defaultValue: '{}'`. | | `comment` | `string` | Adds a comment to the column in the database. | | `dimensions` | `number` | Number of dimensions for vector fields. E.g., `@Field({ type: 'vector', dimensions: 1536 })`. | | `distance` | `VectorDistance` | Default distance metric for vector similarity queries: `'cosine'`, `'l2'`, `'inner'`, `'l1'`. Omitted, the field’s vector index’s, else `'cosine'`. | | `onInsert` | `function` | Generator function for new records (e.g., `() => uuidv7()`). | | `onUpdate` | `function` | Callback invoked on every update (e.g., `() => new Date()` for `updatedAt`). | | `softDelete` | `boolean` \| `function` | Marks the field used for [soft-delete](https://uql-orm.dev/entities/soft-delete.md). `true` stamps the current timestamp (`new Date()`); a callback stamps its result (e.g., `() => Date.now()`). | | `version` | `true` | Makes the column an optimistic lock: an update carries the version it read and writes the next, and one against a row that moved on throws. See [Locking](https://uql-orm.dev/entities/optimistic-locking.md). | | `updatable` | `boolean` | Set to `false` to prevent updates on this field (e.g., `createdAt`). Defaults to `true`. | | `eager` | `boolean` | Whether this field is included in queries by default. Set to `false` for fields (e.g., `password`) that should only be returned when explicitly selected. Defaults to `true`. | | `computed` | `RawExpression` \| aggregate | An expression the database computes, or a `count`/`sum`/`min`/`max`/`avg` over a relation, rather than a value the caller writes. See [computed fields](https://uql-orm.dev/entities/computed-fields.md). | | `stored` | `boolean` | Makes a `computed` field a real column (`GENERATED ALWAYS AS (...) STORED`) the engine keeps up to date, so it can be indexed. | | `references` | `() => Entity` | Marks the column as a foreign key to another entity. The column type is inherited from the target’s primary key, and a column named after that key also gets the many-to-one relation it describes. See [Relations](https://uql-orm.dev/entities/relations.md). | | `autoIncrement` | `boolean` | `@Id` only: enable or disable auto-increment. Defaults to `true` for numeric keys, `false` for strings/UUIDs. | ### Options are checked against each other An option that could never be read is a compile error rather than a silent no-op, and `defineEntity` throws the same message when the entity registers: - `length` belongs to a string column, `precision`, `scale` and `autoIncrement` to a numeric one, `dimensions` and `distance` to a vector. - An unstored `computed` field is never in the schema, so no DDL option applies to it (`index`, `unique`, `defaultValue`, `columnType`), and no generator either, since no insert or update carries it. With `stored: true` it is a real column, so all of those apply again except the ones that would write to it. - A `computed` relation aggregate types the field, so it declares no `type`, and it reads as a subquery, so it takes no `stored`. - `onUpdate` needs an update to fire on, so it cannot join `updatable: false`; a primary key is `NOT NULL` in every engine, so it cannot be `nullable: true`. Only a contradiction is rejected: `nullable: false` on a key states what the key already is, and compiles. ### Nullable columns A column holds `null` unless `nullable: false` says otherwise, and a read hands that `null` back, so the property admits it. Declare a column that cannot be null with `nullable: false`: ```ts @Field({ type: String, nullable: false }) title!: string; // NOT NULL @Field({ type: Date }) publishedAt?: Date | null; // nullable ``` A property the column contradicts is a compile error: `publishedAt?: Date` on a nullable column, `nullable: false` on a `Date | null`. A key is `NOT NULL` on every engine, so it needs neither. The [codemod](https://uql-orm.dev/codemod.md) adds the `| null` to existing entities. ### Naming the key `id`, `_id` and `uuid` are read as the key without help. A key called anything else is named by the `idKey` brand (to improve type-safety), and `@Id` refuses one that names it nowhere: ```ts import { v7 as uuidv7 } from 'uuid'; import { Entity, Id, idKey } from 'uql-orm'; @Entity() export class TaxCategory { [idKey]?: 'pk'; @Id({ type: String, onInsert: uuidv7 }) pk?: string; } ``` Without it the key would resolve to whichever column came first, and `findOneById` would take any of their values. The [codemod](https://uql-orm.dev/codemod.md) writes the brand for entities that need one. ## Composite Primary Keys Declaring `@Id` more than once makes the primary key composite, in declaration order. Two keys are never conventional, so the brand names both: ```ts import { Entity, Id, Field, idKey } from 'uql-orm'; @Entity() export class Enrolment { [idKey]?: 'studentId' | 'courseId'; @Id({ type: Number }) studentId?: number; @Id({ type: String }) courseId?: string; @Field({ type: String }) grade?: string | null; } ``` The table gets one `PRIMARY KEY ("studentId", "courseId")`, and a row is addressed by an object carrying every key: ```ts await pool.findOneById(Enrolment, { studentId: 1, courseId: 'maths' }); await pool.updateOneById( Enrolment, { studentId: 1, courseId: 'maths' }, { grade: 'A' }, ); await pool.deleteOneById(Enrolment, { studentId: 1, courseId: 'maths' }); ``` That object is a `$where` map, so `$where: { studentId: 1, courseId: 'maths' }` is the same filter. **Every key is required**: an id naming only some of them is refused, rather than matching every row that agrees on the rest. The keys stay optional in the *type* (TypeScript cannot accumulate `@Id` across properties), so that check happens when the query runs. ### Pointing at a composite key A foreign key to a composite key is several columns: declare one per key and pair each with its key in the relation: ```ts import { Entity, Field, Id, ManyToOne } from 'uql-orm'; @Entity() export class Note { @Id({ type: Number }) id?: number; @Field({ type: Number }) enrolmentStudentId?: number | null; @Field({ type: String }) enrolmentCourseId?: string | null; @ManyToOne({ entity: () => Enrolment, references: (note, enrolment) => [ { local: note.enrolmentStudentId, foreign: enrolment.studentId }, { local: note.enrolmentCourseId, foreign: enrolment.courseId }, ], }) enrolment?: Enrolment; } ``` `@Field({ references: () => Enrolment })` is refused here: one column cannot reference two. The pairs make one `FOREIGN KEY` spanning both columns: two single-column constraints would not enforce the pair, and the engine rejects them anyway. A junction to a composite side needs a `@ManyToOne` for that side, for the same reason. ### What is not supported yet Reads, writes and cascading deletes take every key, and every write reports the whole key. The paths below still need one value to name a row, so each refuses a composite key with an error: | Path | Why | | - | - | | Saving a relation | Writing the parent’s key into a child takes a statement per parent, not one over a list. | | MongoDB | A compound `_id` is a sub-document whose field order decides equality: a different document shape, not a translation. | | The HTTP `/:id` route | One path segment, and how several columns share one is still to be settled. | ## Choosing Your Primary Key Strategy > **Choosing between Integer and UUID keys** > > - **Integers** (`@Id({ type: Number })`): the database manages ID generation. Faster joins and smaller indexes make them a good default for internal tables and small-to-medium applications. > - **UUIDs** (`@Id({ type: 'uuid', onInsert: ... })`): better for distributed systems, multi-tenant SaaS, and public-facing APIs. They avoid ID enumeration (users guessing `/users/1`, `/users/2`) and can be generated on the client before the row exists. # Imperative Definition > Define entities without decorators using defineEntity, with the same options as the decorator API. Source: https://uql-orm.dev/entities/imperative `defineEntity` takes the same options as the [decorators](https://uql-orm.dev/entities/basic.md) and registers identical metadata, checked the same way bar one foreign-key case. Nothing is decorated, so no decorator syntax reaches your build. Reach for it when: - Your transformer implements no decorators, like Oxc (Vite 8’s own). - You are in a NestJS app, which must keep `experimentalDecorators` on for its own DI. That rules out UQL’s standard decorators in the same project. - You run the CLI on plain `node`. Decorators are not erasable syntax, so a `uql.config.ts` that imports decorated entities needs `bun` or `node --import tsx`. A `defineEntity` entity file is erasable, so type stripping alone loads it. - You generate entities at runtime, or want to leave domain classes unannotated. - You are writing JavaScript, where a decorator is a `SyntaxError` unless Bun or a transpiler gets to the file first. A `defineEntity` call just runs. Two forms, one registry (`@Entity` itself calls `defineEntity`): | Form | Use it when | | - | - | | `defineEntity(Class, opts)` | The whole shape is known: the options are checked against the properties the class declares. | | `defineField(Class, 'name', opts)` and friends | The shape arrives column by column, or is [only known at runtime](https://uql-orm.dev/entities/runtime.md). | ## Using `defineEntity` ```ts import { v7 as uuidv7 } from 'uuid'; import { defineEntity } from 'uql-orm'; export class User { id?: string; name?: string | null; email?: string | null; } export class Post { id?: number; title?: string; authorId?: string | null; author?: User; publishedAt?: Date | null; } defineEntity(User, { fields: { id: { type: 'uuid', isId: true, onInsert: uuidv7 }, name: { type: String, index: true }, email: { type: String, unique: true, comment: 'User login email' }, }, }); defineEntity(Post, { fields: { id: { type: Number, isId: true }, title: { type: String, nullable: false }, authorId: { references: () => User }, publishedAt: { type: Date, nullable: true }, }, relations: { author: { cardinality: 'm1', entity: () => User, references: (post) => post.authorId, }, }, indexes: [{ columns: (post) => [post.title, post.authorId], unique: true }], filters: { published: { where: { publishedAt: { $ne: null } }, default: false }, }, }); ``` Every entry of either form has a decorator equivalent: | Key | Decorator equivalent | Notes | | - | - | - | | `name` | `@Entity({ name })` | Custom table name. Defaults to the class name, so [name it explicitly if your build minifies](https://uql-orm.dev/entities/basic.md#if-your-build-minifies-name-the-table). | | `fields` | `@Field` / `@Id` | Same [field options](https://uql-orm.dev/entities/basic.md#field-options); mark the primary key with `isId: true` instead of `@Id`. | | `relations` | `@OneToOne`, `@OneToMany`, `@ManyToOne`, `@ManyToMany` | Same [relation options](https://uql-orm.dev/entities/relations.md), plus `cardinality`: `'11'`, `'1m'`, `'m1'`, or `'mm'`. | | `indexes` | `@Index` | `{ columns: (post) => [post.title], name?, unique?, type?, where? }`, see [Indexes](https://uql-orm.dev/entities/indexes.md). | | `hooks` | Hook decorators | Maps each [lifecycle event](https://uql-orm.dev/entities/lifecycle-hooks.md) to the methods it runs: `{ beforeInsert: (post) => [post.stamp] }`. | | `filters` | `@Filter` | Same [filter options](https://uql-orm.dev/querying/filters.md#defining-a-filter): `{ where, default?, security?, onMissing? }`. | | `extends` | `class Child extends Base` | The base to inherit fields, relations, hooks and filters from. See [Inheritance](https://uql-orm.dev/entities/inheritance.md#naming-a-base-you-cannot-extend). | Two things the decorators check go unchecked here. A foreign key declared with `references` and no `type` resolves its column type from the referenced primary key under both APIs, but only `@Field` also checks the property’s own type against that key, so `authorId?: number` pointing at a `uuid` key compiles here and not there. And where `@Id` refuses a key the type level cannot [name](https://uql-orm.dev/entities/basic.md#naming-the-key), `isId: true` does not: brand an unconventional or composite key yourself, or every by-id method is typed against the wrong column. ## Incremental registration For dynamic schemas, register piece by piece with `defineField`, `defineId`, `defineRelation`, `defineIndex`, `defineFilter`, and `defineHook`, then call `defineEntity` last. It validates the metadata (fields present, exactly one primary key) and finalizes the entity: ```ts import { v7 as uuidv7 } from 'uuid'; import { defineEntity, defineField, defineFilter, defineHook, defineId, defineIndex, defineRelation, type HookContext, } from 'uql-orm'; class Article { id?: string; title?: string; authorId?: string; author?: User; createdAt?: Date; publishedAt?: Date; stamp(_ctx: HookContext): void { this.createdAt = new Date(); } } defineId(Article, 'id', { type: 'uuid', onInsert: uuidv7 }); defineField(Article, 'title', { type: String, nullable: false }); defineField(Article, 'createdAt', { type: Date }); defineField(Article, 'publishedAt', { type: Date, nullable: true }); defineField(Article, 'authorId', { references: () => User }); defineRelation(Article, 'author', { cardinality: 'm1', entity: () => User, references: (article) => article.authorId, }); defineIndex(Article, { columns: (article) => [article.title], unique: true }); defineFilter(Article, 'published', { where: { publishedAt: { $ne: null } }, default: false, }); defineHook(Article, 'stamp', 'beforeInsert'); defineEntity(Article, { name: 'articles' }); ``` Both APIs write to the same metadata registry, so you can mix styles within one project, and everything downstream (querying, migrations, the [HTTP transport](https://uql-orm.dev/http.md)) behaves identically. ## A schema defined at runtime When the shape is data rather than source (a CMS content type an admin creates, a tenant whose columns are rows in a table), the same call registers it and `sync({ entity })` gives it a table. See [Runtime Schemas](https://uql-orm.dev/entities/runtime.md). # Runtime Schemas > Define entities from data (a CMS content type an admin creates, a tenant whose columns are rows in a table) and apply them to the database while the process runs. Source: https://uql-orm.dev/entities/runtime Some schemas only exist at runtime: a CMS content type an admin creates through a UI, or a tenant whose columns are rows in another table. UQL reads no TypeScript types at runtime, so this needs no separate API: the [imperative](https://uql-orm.dev/entities/imperative.md) one registers a class you mint yourself, column by column. ```typescript import { defineEntity, removeEntity, type ColumnType, type Scalar, } from 'uql-orm'; import { Migrator } from 'uql-orm/migrate'; /** What the admin UI stored: one column per field. */ type ContentType = { name: string; fields: { name: string; type: ColumnType }[]; }; async function registerContentType(contentType: ContentType) { // Every value is a scalar column; `id` is declared to type an insert's id. Named after the // content type, so its errors read like any other entity's. const entity = { [contentType.name]: class { id!: string; [column: string]: Scalar; }, }[contentType.name]; defineEntity(entity, { name: contentType.name, fields: { id: { type: 'uuid', isId: true }, ...Object.fromEntries( contentType.fields.map((field) => [field.name, { type: field.type }]), ), }, }); // One entity, one table: a new one costs an existence check and a `CREATE TABLE IF NOT EXISTS`. await new Migrator(pool).sync({ entity }); return entity; } ``` Columns every content type carries (`createdBy`, a tenant key) belong on a base named with [`extends`](https://uql-orm.dev/entities/inheritance.md#naming-a-base-you-cannot-extend), which a minted class has no way to extend. If your UI keeps its own vocabulary (`text`, `longtext`, `money`), translate it to a [column type](https://uql-orm.dev/entities/basic.md#field-options) as you build the fields. A SQL type states what the column is; `String` would leave its width to a default. The class is only an identity for the registry: nothing constructs it, and rows come back as plain objects. Pass it to a querier like any hand-written entity. ## Applying it to the database `sync({ entity })` is the runtime path: one entity, one table. - **The table does not exist**: one existence check, then `CREATE TABLE IF NOT EXISTS`, with no catalogue read. Instances racing the same save settle instead of colliding. - **The table exists**: UQL reads its columns and applies the same additive changes a full [`sync`](https://uql-orm.dev/migrations.md#syncing-without-a-migration-file) would. A new column is added; a retyped or dropped one is refused and stays a migration. A `Migrator` built without an `entities` option reads the registry live, so an entity registered later is included with no restart. One pinned to an explicit list still syncs an entity outside it. ## Editing and deleting a content type Registering the same content type again mints a second class, and two entities then map one table. Keep the entity `registerContentType` returned and forget the previous one first: ```typescript const previous = registered.get(contentType.name); if (previous) removeEntity(previous); registered.set(contentType.name, await registerContentType(contentType)); ``` A deleted content type needs `removeEntity(entity)` too; otherwise the registry keeps it, and its table in every diff, for the life of the process. ## What survives at compile time The row type is whatever the minted class says; the one above is a bag of scalar columns with a typed key. Queries are checked against it: `$where`, `$sort`, `$populate` and projections take any column by name, with every operator available. Column *names* cannot be checked, because none was known at compile time. Declaring `id!: string` beside the index signature types the key: an insert reports `string | undefined` instead of every scalar, so `findOneById` takes what the insert returned. Where the key is not called `id`, name it with the `idKey` brand: ```typescript import { idKey, type Scalar } from 'uql-orm'; class Content { declare [idKey]?: 'pk'; pk!: string; [column: string]: Scalar; } ``` To get column names back, generate an interface per content type (`npx uql-migrate types` writes one for every registered entity) and declare the class with those columns instead of an index signature: ```typescript class Post { id!: string; title?: string; views?: number; } ``` ## What this deliberately does not do - **Change a column.** Retyping, renaming or dropping one is refused by a sync and stays a migration on every engine. - **Lock across instances.** `CREATE TABLE IF NOT EXISTS` settles two instances answering the same admin save; anything stronger is yours to coordinate. - **Validate rows against the content type.** The database enforces what the DDL says; the rest belongs to your application. # Relations > Define one-to-one, one-to-many, and many-to-many relations between UQL entities. Source: https://uql-orm.dev/entities/relations Relations are declared with four decorators on the entity class. `mappedBy` names the other side with a callback, `(post) => post.author`: a typo is a compile error, and a rename in your editor reaches it. ```ts import { v7 as uuidv7 } from 'uuid'; import { Entity, Id, Field, OneToOne, OneToMany, ManyToOne, ManyToMany, } from 'uql-orm'; @Entity() export class User { @Id({ type: 'uuid', onInsert: uuidv7, }) id?: string; @Field({ type: String }) name?: string | null; /** * One-to-One: A user has one profile. */ @OneToOne({ entity: () => Profile, mappedBy: (profile) => profile.user, cascade: true, }) profile?: Profile; /** * One-to-Many: A user can have many posts. */ @OneToMany({ entity: () => Post, mappedBy: (post) => post.author, }) posts?: Post[]; } @Entity() export class Profile { @Id({ type: 'uuid', onInsert: uuidv7, }) id?: string; @Field({ type: String }) picture?: string | null; /** * Foreign key column. The 'references' option points at the target entity; * the column type is inherited from the target's primary key. */ @Field({ references: () => User }) userId?: string | null; @OneToOne({ entity: () => User, references: (profile) => profile.userId }) user?: User; } @Entity() export class Post { @Id({ type: Number }) id?: number; @Field({ type: String }) title?: string | null; @Field({ references: () => User }) authorId?: string | null; @ManyToOne({ entity: () => User, references: (post) => post.authorId }) author?: User; /** * Many-to-Many: A post can have many tags. * 'through' specifies the pivot entity. */ @ManyToMany({ entity: () => Tag, through: () => PostTag, cascade: true, }) tags?: Tag[]; } @Entity() export class Tag { @Id({ type: 'uuid', onInsert: uuidv7, }) id?: string; @Field({ type: String }) name?: string | null; } @Entity() export class PostTag { @Id({ type: 'uuid', onInsert: uuidv7, }) id?: string; @Field({ references: () => Post }) postId?: number | null; @Field({ references: () => Tag }) tagId?: string | null; } ``` > **The FK column and its relation** > > The side of a to-one holding the foreign key declares it, `@Field({ references: () => User }) authorId`, and names it in the relation, `references: (post) => post.authorId`: the relation does not compile without it. The column is then a typed field like any other, to select, filter and write, and a rename of either follows the other. The column takes its type from the key it references, and `references` only compiles on a column that can hold that key, as each column of a pair must hold the key it is paired with. > > A column on its own is a foreign key and nothing more: its constraint reaches generated DDL, but there is nothing to `$populate` until a relation declares it. > > The exception is a [composite key](https://uql-orm.dev/entities/basic.md#composite-primary-keys): one column cannot reference several, so `@Field({ references })` is refused there. Declare a column per key and [pair each](https://uql-orm.dev/entities/basic.md#pointing-at-a-composite-key) in the relation’s `references`. A junction to a composite side needs that `@ManyToOne` rather than two plain columns, which is also what gives it the foreign key constraint. > > On first read, an entity refuses a join on a member that is not a column, and a one-column join whose foreign key references another entity than the one the relation joins, on either side: `mappedBy: (resource) => resource.creatorId` on `Person` fails when `creatorId` references `User`. ## How a to-many says where its rows are A to-one relation carries its own foreign key, so nothing more is needed. A to-many has no such column and needs one of three, which the compiler requires and the entity re-checks when it is first resolved: | Option | Use | | - | - | | `mappedBy` | The inverse side: names the field on the other entity that holds the foreign key or relation. | | `through` | A junction entity with a foreign key to each side. Works for `@ManyToMany` and `@OneToMany` alike. | | `references` | The join columns where no convention fits: `(order, customer) => [{ local: order.customerCode, foreign: customer.code }]`. A `through` relation takes none: it joins by the junction’s column referencing each side. | With `through`, each join column is the junction’s one column referencing that side, `postId` and `tagId` above, whatever it is called, so a rename follows. A junction with no such column for a side, or with two, is reported when the entity is resolved, rather than at the first query. ## Querying Relations Scalar columns go in `$select`, related entities in `$populate`, each with its own selection and filter: ```ts title="You write" const posts = await pool.findMany(Post, { $select: { id: true, title: true }, $populate: { author: { $select: { id: true, name: true }, }, tags: { $select: { name: true }, $where: { name: { $istartsWith: 'typescript' } }, }, }, $where: { author: { name: 'Roger' }, }, }); ``` PostgreSQL: ```sql -- One statement: the author is joined, the tags are a correlated subquery, the author filter an EXISTS. SELECT "Post"."id", "Post"."title", "author"."id" "author.id", "author"."name" "author.name", (SELECT COALESCE(JSON_AGG("_uql_row"), '[]'::json) FROM (SELECT "tags"."name" FROM "Tag" "tags" WHERE "tags"."name" ILIKE $1 AND "tags"."id" IN ( SELECT "PostTag"."tagId" FROM "PostTag" WHERE "PostTag"."postId" = "Post"."id")) "tags" CROSS JOIN LATERAL (SELECT "tags"."name") "_uql_row") "tags" FROM "Post" LEFT JOIN "User" "author" ON "author"."id" = "Post"."authorId" WHERE EXISTS ( SELECT 1 FROM "User" "author_2" WHERE "author_2"."id" = "Post"."authorId" AND "author_2"."name" = $2 ) -- values: ['typescript%', 'Roger'] ``` [Querying relations](https://uql-orm.dev/querying/relations.md) covers deep filtering and selection. Against a database that already has tables, these decorators are written for you: `generate:from-db` [infers them from the foreign keys it finds](https://uql-orm.dev/migrations.md#relations-when-scaffolding). # Computed Fields > Fields the database computes rather than the caller writes, spliced into each query or stored as a real column. Source: https://uql-orm.dev/entities/computed-fields `@Field({ computed })` declares a value the database produces, not one the caller writes. It never takes part in an insert or an update, and reads like any other field. Declare it `readonly`, and a write payload naming it is a compile error rather than a value silently dropped. It takes one of two forms. An expression over the row - arithmetic, a string built from two columns, a date part - which is SQL, below; or a [relation aggregate](#relation-aggregates), which is data and reads on every engine. ```ts import { Entity, Id, Field, raw } from 'uql-orm'; @Entity() export class Product { @Id({ type: Number }) id?: number; @Field({ type: Number }) cost?: number | null; @Field({ type: Number }) salePrice?: number | null; @Field({ type: Number, // Each ref renders as its column, qualified and escaped, so the expression survives a join, // a subquery whose columns would shadow a bare name, and a rename in your editor. computed: (product) => raw`${product.salePrice} - ${product.cost}`, }) readonly profit?: number | null; } ``` ## `stored`: the one dial By default nothing is persisted: the expression is spliced into every statement that reads the field. Add `stored: true` and it becomes a real column the engine keeps up to date, so it can be indexed, constrained and read without recomputing. ```ts @Field({ type: Number, computed: (product) => raw`${product.salePrice} - ${product.cost}`, stored: true, }) profit?: number | null; ``` Queries do not change. `$select`, `$where` and `$sort` read the field the same way either side of the dial, so `stored` is something you flip after profiling without touching a call site. | | unstored (default) | `stored: true` | | - | - | - | | where the value is | recomputed per statement | a column, written by the engine | | DDL | none | `GENERATED ALWAYS AS (...) STORED` | | indexable | no | yes, like any column | | expression | anything the dialect parses | must be deterministic, over the row | A stored column needs a `type`, since a migration has to spell one out. Support is Postgres 12+, MySQL 5.7+, MariaDB 5.2+, SQLite 3.31+. Either way the expression is SQL, which MongoDB does not evaluate: a query naming one there is refused rather than answered with `undefined`. ## Relation aggregates The other form reads a relation instead of writing SQL: `count()`, `sum()`, `min()`, `max()` and `avg()`, off the same refs. The aggregate says what the field holds, so it declares no `type`. ```ts import { Entity, Id, Field, ManyToOne, OneToMany } from 'uql-orm'; @Entity() export class Order { @Id({ type: Number }) id?: number; @OneToMany({ entity: () => OrderItem, mappedBy: (item) => item.order }) items?: OrderItem[]; @Field({ computed: (order) => order.items.count() }) readonly itemCount?: number; @Field({ computed: (order) => order.items.sum((item) => item.amount) }) readonly total?: number; @Field({ computed: (order) => order.items.max((item) => item.amount) }) readonly largestItem?: number | null; // Which of the related rows it reads. @Field({ computed: (order) => order.items.count({ $where: { refunded: false } }), }) readonly paidCount?: number; // ...and, for a value aggregate, a page of them. @Field({ computed: (order) => order.items.sum((item) => item.amount, { $sort: { amount: -1 }, $limit: 5, }), }) readonly topFiveTotal?: number; } @Entity() export class OrderItem { @Id({ type: Number }) id?: number; @Field({ references: () => Order, type: Number }) orderId?: number | null; @ManyToOne({ entity: () => Order, references: (item) => item.orderId }) order?: Order; @Field({ type: Number }) amount?: number | null; @Field({ type: Boolean }) refunded?: boolean | null; } ``` `count` and `sum` read `0` over a parent with no rows; `min`, `max` and `avg` read `null`, and the property has to admit it. Only a to-many can be aggregated, and `sum` and `avg` only over a numeric column. A many-to-many’s `count` tallies its links; a column of its targets reads each target once. Each one is a correlated subquery inside the parent’s own statement, so no related row is loaded: ```ts title="You write" const orders = await pool.findMany(Order, { $select: { id: true, total: true }, $where: { itemCount: { $gte: 2 } }, $sort: { total: -1 }, }); ``` PostgreSQL: ```sql SELECT "id", (SELECT COALESCE(SUM("items"."amount"), 0) FROM "OrderItem" "items" WHERE "items"."orderId" = "Order"."id") "total" FROM "Order" WHERE (SELECT COUNT(*) FROM "OrderItem" "items_2" WHERE "items_2"."orderId" = "Order"."id") >= $1 ORDER BY (SELECT COALESCE(SUM("items_3"."amount"), 0) FROM "OrderItem" "items_3" WHERE "items_3"."orderId" = "Order"."id") DESC ``` MongoDB reads the same field: the declaration is data, not SQL, so it renders there as a `$lookup` ending in a `$count` or a `$group`. ### Reading only some of the rows `topFiveTotal` above is the shape: a value aggregate reading only some of the related rows takes the `$sort` that picks which ones, together with its `$limit`, since a total over five of them is defined by nothing else. A `count` takes `$limit` on its own - an order changes which rows a page holds, never how many - and caps the tally, which is how you ask “at least 500?” without counting a million. ### Loading An aggregate reads the related rows, so, like a relation, it loads only where a query names it in `$select`, `$where` or `$sort`. `eager: true` puts it in every read. `stored: true` is not accepted on one: a correlated subquery is not something an engine keeps in a generated column. ## Querying Select and filter them like any other field. ### Selection ```ts title="You write" const products = await pool.findMany(Product, { $select: { id: true, profit: true }, }); ``` PostgreSQL: ```sql SELECT "id", "salePrice" - "cost" "profit" FROM "Product" ``` ### Filtering ```ts title="You write" const products = await pool.findMany(Product, { $select: { id: true }, $where: { profit: { $gte: 10 }, }, }); ``` PostgreSQL: ```sql SELECT "id" FROM "Product" WHERE "salePrice" - "cost" >= $1 ``` # Soft Delete > Mark rows as deleted without removing them, using a softDelete field. Source: https://uql-orm.dev/entities/soft-delete Soft-delete marks a row as deleted instead of removing it, so an audit trail stays readable, a mistaken delete stays recoverable, and rows pointing at it stay valid. ## Configuration Mark one field with `softDelete` in its `@Field` options. Its presence makes the entity soft-deletable; there’s no separate `@Entity` flag. The value controls what gets stamped on delete: pass `true` to stamp the current timestamp (`new Date()`), or a callback (e.g. `() => Date.now()` for an epoch-millis column) to stamp anything else. An entity may have at most one soft-delete field. ```ts import { Entity, Id, Field } from 'uql-orm'; @Entity() export class User { @Id({ type: Number }) id?: number; @Field({ type: String }) name?: string | null; /** Stamped with `new Date()` on delete; pass a callback for a different value. */ @Field({ type: 'timestamptz', softDelete: true, }) deletedAt?: Date | null; } ``` ## Automatic Transformation Deletes become updates, and reads leave deleted rows out. ### 1. Deleting a record ```ts title="You write" await pool.deleteOneById(User, 1); ``` PostgreSQL: ```sql -- Only already-live rows are stamped UPDATE "User" SET "deletedAt" = $1 WHERE "id" = $2 AND "deletedAt" IS NULL ``` ### 2. Querying records By default, soft-deleted records are excluded from all queries. ```ts title="You write" const users = await pool.findMany(User, { $select: { id: true, name: true } }); ``` PostgreSQL: ```sql SELECT "id", "name" FROM "User" WHERE "deletedAt" IS NULL ``` Under the hood, soft-delete is the built-in **`softDelete`** [query filter](https://uql-orm.dev/querying/filters.md) (auto-registered from the `@Field({ softDelete })` above), so it composes with any other filters you define. ## Reading trashed rows **Only trashed**: constrain the field in your `$where`. The soft-delete filter steps aside for any key you set, so this stays a plain, serializable query: ```ts const trashed = await pool.findMany(User, { $where: { deletedAt: { $ne: null } }, }); // generates: ... WHERE deletedAt IS NOT NULL ``` **Live + trashed**: this turns the filter off, so it is a server-side option (`withDeleted()`) and never part of the serializable query, the same reason filter bypass never crosses the HTTP wire: ```ts import { withDeleted } from 'uql-orm'; const all = await pool.findMany(User, { $where: {/* ... */} }, withDeleted()); ``` It applies to the read’s own rows: populated relations still hide their trashed ones. ## Restoring `restore` un-deletes rows by setting the soft-delete field back to `null` (it finds trashed rows even though the filter is normally on): ```ts await pool.restoreOneById(User, 1); await pool.restoreMany(User, { $where: { companyId: 5 } }); ``` ## Hard Delete Pass `{ hardDelete: true }` to permanently remove rows instead of stamping them (this ignores the soft-delete filter, so already-deleted rows are removed too): ```ts await pool.deleteOneById(User, 1, { hardDelete: true }); await pool.deleteMany(User, { $where: { companyId: 5 } }, { hardDelete: true }); ``` Restore does not cascade; restore related entities explicitly. # Optimistic Locking > Guard a write across requests with a version column, so an update against a row someone else changed throws instead of overwriting it. Source: https://uql-orm.dev/entities/optimistic-locking `$lock` holds a row until the transaction ends, so it only reaches as far as a transaction does. A user who loads a form, thinks, and saves a minute later is two requests, with no transaction spanning them, and four backends have no [row lock](https://uql-orm.dev/querying/locking.md) at all. A version column closes that gap. The row carries a counter, the write carries the counter it read, and the update matches on it: ```ts import { v7 as uuidv7 } from 'uuid'; import { Entity, Field, Id, versionKey } from 'uql-orm'; @Entity() export class Post { [versionKey]?: 'version'; @Id({ type: 'uuid', onInsert: uuidv7 }) id?: string; @Field({ type: String }) title?: string | null; @Field({ type: Number, version: true }) version?: number; } ``` ```ts const postId = '0190a1b2-c3d4-7e5f-8a6b-7c8d9e0f1a2b'; const post = await pool.findOneById(Post, postId); // version: 3 await pool.updateOneById(Post, postId, { title: 'Edited', version: post!.version!, }); ``` ```sql title="One statement, on every engine" UPDATE "Post" SET "title" = ?, "version" = ? WHERE "id" = ? AND "version" = ? ``` If another writer got there first, the row is no longer at version 3, the update matches nothing, and it throws instead of overwriting their work: ```ts import { UqlOptimisticLockError } from 'uql-orm'; try { await pool.updateOneById(Post, postId, { title: 'Edited', version: 3, }); } catch (err) { if (err instanceof UqlOptimisticLockError) { // err.expected === 3, err.actual === 4, and err.status === 409 } } ``` The error says which of three things happened - the row moved on, it is gone, or another condition of the `$where` excluded a row still at that version - because it reads the row by its id once before throwing. `queryErrorKind(err)` answers `'optimisticLock'`, and over the [HTTP transport](https://uql-orm.dev/http.md) it becomes a `409` - classify it that way rather than by `instanceof`, which you need only to read `expected` and `actual`. A payload that carries no version at all throws `UqlUsageError` instead, kind `'usage'` and a `400` there: the run-time half of the compile-time rule. ## What the version costs you - **The payload has to carry it.** That is a compile error, not a runtime surprise: `updateOneById(Post, id, { title })` does not type-check on a versioned entity. The `versionKey` brand on the class is what carries `version: true` to the type level, since a decorator’s options never reach the entity’s type. - **The column is the lock’s**, so it is `NOT NULL DEFAULT 0` and the ORM writes it. `updatable`, `onInsert`, `onUpdate`, `defaultValue`, `computed` and `isId` are refused on it. - **One row, named by its id.** One version cannot say which of many rows it belongs to, so a versioned update filters by the primary key. Other conditions may sit beside it - `{ id, tenantId }` is fine - but `updateMany` over a filter that names many rows is refused. - **One statement, or none.** A versioned update takes no `$sort`, `$limit` or `$skip`, its payload writes no relation, and on engines that cannot filter by a relation in an `UPDATE` its filter reads none: each of those would settle the rows in a separate statement first, putting the race back in the gap. - **Delete and restore take no version.** They move the row’s lifecycle rather than its content, and two restores racing agree on the result anyway, so `deleteOneById` and `restoreOneById` work as on any entity and leave the version alone. - **`saveOne`, `saveMany`, `upsertOne` and `upsertMany` are refused** on a versioned entity. MySQL’s `ON DUPLICATE KEY UPDATE` takes no `WHERE`, so the check cannot be expressed portably in an upsert, and a silently unguarded write is worse than a refusal. Insert or update it explicitly. ## Which to use | | `$lock` | `version` | | - | - | - | | Reaches | one transaction | any number of requests | | Costs | a held lock, and waiting writers | a retry when a conflict happens | | Engines | all but SQLite, libSQL, Turso, D1 and MongoDB | all of them | | Fails by | waiting, or `$wait: 'nowait'` | throwing `UqlOptimisticLockError` | | Caught by | kind `'retryable'` | kind `'optimisticLock'` | Use `$lock` when the read and the write are in the same transaction and contention is likely. Use a version when they are not, which on the web is most of the time. ## Next Steps - [Row Locking](https://uql-orm.dev/querying/locking.md): `$lock`, wait policies and the `SKIP LOCKED` work queue. - [Error kinds](https://uql-orm.dev/querying/errors.md): `optimisticLock` beside the constraint kinds. - [Soft Delete](https://uql-orm.dev/entities/soft-delete.md): what a delete does to a versioned row. # Lifecycle Hooks > Run logic before and after insert, update, delete, and load operations with hook decorators and global listeners. Source: https://uql-orm.dev/entities/lifecycle-hooks Hooks run custom logic at points in an entity’s lifecycle: validation, timestamps, slug generation, data masking. | Decorator | Fires when | | - | - | | `@BeforeInsert()` | Before a new record is inserted | | `@AfterInsert()` | After a new record is inserted | | `@BeforeUpdate()` | Before a record is updated | | `@AfterUpdate()` | After a record is updated | | `@BeforeUpsert()` | Before a record is upserted | | `@AfterUpsert()` | After a record is upserted | | `@BeforeDelete()` | Before a record is deleted, once per row | | `@AfterDelete()` | After a record is deleted, once per row | | `@AfterLoad()` | After a record is loaded from the database | All hooks receive a `HookContext` with the active `querier`, so you can perform additional DB operations within the same transaction. Hooks run once per payload, with `this` bound to it: the record for a write, the update payload for `@BeforeUpdate`/`@AfterUpdate`, each loaded row for `@AfterLoad`. Populated rows run `@AfterLoad` too, children before their parent. `upsertOne`/`upsertMany` run `@BeforeUpsert`/`@AfterUpsert`, never the insert or update pair: the database picks the branch as the statement runs, so neither of those is the honest event. `saveOne`/`saveMany` fire the same pair for any row that names its key, since it is the same statement. `findManyStream` runs no `@AfterLoad`: a stream has no point at which every row has been seen. ## Entity-Level Hooks ```ts import { Entity, Id, Field, BeforeInsert, AfterLoad } from 'uql-orm'; @Entity() export class Article { @Id({ type: Number }) id?: number; @Field({ type: String }) title?: string | null; @Field({ type: String }) slug?: string | null; @Field({ type: String }) internalCode?: string | null; @BeforeInsert() generateSlug() { if (this.title) { this.slug = this.title.toLowerCase().replace(/\s+/g, '-'); } } @AfterLoad() maskInternalCode() { this.internalCode = '***'; } } ``` ### Mutation Semantics - **`@BeforeInsert` / `@BeforeUpdate`**: Mutations via `this` are **propagated** to the payload. This is how you transform data before persistence. - **`@AfterLoad`**: Mutations via `this` are **propagated**. This is how you derive a value in JavaScript and mask data after loading. - **`after*` hooks** (`@AfterInsert`, `@AfterUpdate`, `@AfterUpsert`, `@AfterDelete`): Side-effect only, for logging, cache invalidation, or notifications. Data is already persisted, and `this` is the row as written: an `@AfterInsert` sees the generated id and every `onInsert` value, an `@AfterUpdate` every `onUpdate` value, an `@AfterUpsert` the id the database reported. That row is a copy, so the object you passed is left as it was. Throwing from one reports its own failure; it does not unwrite the row unless a transaction is open. ### Delete Hooks `@BeforeDelete` and `@AfterDelete` run once per row being deleted, with `this` bound to the row as it was **before** the delete. Both see that same snapshot, so an `@AfterDelete` can still name what it removed: ```ts import { AfterDelete } from 'uql-orm'; @Entity() export class Attachment { @Id({ type: Number }) id?: number; @Field({ type: String }) storageKey?: string | null; @AfterDelete() async dropFile(this: Attachment) { await bucket.delete(this.storageKey!); } } ``` This applies to soft deletes and to children removed by a `cascade: 'delete'` relation. Reading the rows back costs one extra query, paid only by entities that declare a delete hook (or pools that register a listener for the event); mutating the snapshot changes nothing, since the row is on its way out. ### Async Hooks An `async` hook is awaited before the operation proceeds: ```ts import type { HookContext } from 'uql-orm'; @Entity() export class User { @Id({ type: Number }) id?: number; @Field({ type: String }) email?: string | null; @BeforeInsert() async validateEmail(ctx: HookContext) { const existing = await ctx.querier.count(User, { $where: { email: this.email }, }); if (existing > 0) { throw new Error('Email already exists'); } } } ``` `ctx.querier` runs in the same transaction as the operation that triggered the hook. ### Multiple Hooks Per Event Several hooks for one event execute in **declaration order**: ```ts @Entity() export class Post { @Id({ type: Number }) id?: number; @Field({ type: String }) title?: string | null; @Field({ type: String }) slug?: string | null; @BeforeInsert() normalizeTitle() { this.title = this.title?.trim(); } @BeforeInsert() generateSlug() { this.slug = this.title?.toLowerCase().replace(/\s+/g, '-'); } } ``` ### Stacking Decorators A single method can be registered for multiple events: ```ts import { BeforeUpdate } from 'uql-orm'; @BeforeInsert() @BeforeUpdate() normalizeEmail() { if (this.email) { this.email = this.email.toLowerCase().trim(); } } ``` ### Hook Inheritance Hooks are inherited from parent entities. Parent hooks execute **first**: ```ts class BaseEntity { @Id({ type: Number }) id?: number; @Field({ type: Date }) updatedAt?: Date | null; @BeforeInsert() @BeforeUpdate() setTimestamp() { this.updatedAt = new Date(); } } @Entity() class Post extends BaseEntity { @Field({ type: String }) title?: string | null; @BeforeInsert() validate() { if (!this.title) throw new Error('Title is required'); } } // On insert: setTimestamp() runs first, then validate() ``` ## Global Listeners For cross-cutting concerns (audit logging, automatic timestamps across all entities, cache invalidation), register **global listeners** on the querier pool: ```ts import { type QuerierListener } from 'uql-orm'; import { PgQuerierPool } from 'uql-orm/postgres'; const auditListener: QuerierListener = { afterInsert({ entity, payloads, querier }) { console.log(`Inserted ${payloads.length} ${entity.name} records`); }, afterUpdate({ entity, querier }) { console.log(`Updated ${entity.name} records`); }, afterDelete({ entity }) { console.log(`Deleted ${entity.name} records`); }, }; const pool = new PgQuerierPool( { connectionString: process.env.DATABASE_URL }, { listeners: [auditListener] }, ); ``` Global listeners receive a `ListenerContext` with: | Property | Type | Description | | - | - | - | | `entity` | `Type` | The entity class | | `querier` | `Querier` | The active querier (same transaction) | | `payloads` | `E[]` | The entity payloads | | `event` | `HookEvent` | The event name | Global listeners fire first, in registration order, then entity hooks in declaration order with parent hooks first. That order is what lets a listener inject audit metadata the entity hooks then read. Hooks live at the querier layer, not the dialect, so they behave identically on every supported database. # Indexes > Define simple and composite indexes on your entities with @Field({ index }) and @Index. Source: https://uql-orm.dev/entities/indexes Indexes are declared on the entity: `@Field({ index })` for a single column, `@Index` at the class level for composite and specialized ones. ## Simple Indexes ```ts @Entity() export class User { @Id({ type: Number }) id?: number; @Field({ type: String, index: true }) // Adds an auto-named index: ___idx email?: string | null; @Field({ type: String, index: 'display_name_idx' }) // Adds a named index displayName?: string | null; } ``` ## Foreign Keys Every foreign key column is indexed, since a relation looks its rows up by it and only MySQL indexes one on its own. An index that already leads with the column serves instead, as do the primary key and a unique column, so none is added twice. `index: false` opts a column out: ```ts @Entity() export class Post { @Id({ type: Number }) id?: number; @Field({ references: () => User }) // Indexed: Post__authorId_idx authorId?: number | null; @Field({ references: () => User, index: false }) // Not indexed editorId?: number | null; } ``` ## Composite Indexes A composite index answers a filter on all its columns directly; two single-column indexes leave the planner to combine two separate scans. An audit log searched by `entityType` and `entityId` and ordered by `createdAt` wants one index over the three. `@Index` reads them off the class’s refs, so a typo is a compile error and a rename in your editor reaches every index: ```ts import { Entity, Id, Field, Index } from 'uql-orm'; @Index( (auditLog) => [auditLog.entityType, auditLog.entityId, auditLog.createdAt], { name: 'audit_lookup_idx' }, ) @Entity() export class AuditLog { @Id({ type: Number }) id?: number; @Field({ type: String }) entityType?: string | null; // e.g., 'User', 'Post' @Field({ type: String }) entityId?: string | null; // e.g., 'uuid-123' @Field({ type: 'timestamptz' }) createdAt?: Date | null; @Field({ type: String }) action?: string | null; // e.g., 'create', 'update' } ``` ## Customizing Indexes | Option | Type | Description | | - | - | - | | `name` | `string` | Custom index name. | | `unique` | `boolean` | Whether the index should enforce uniqueness. Defaults to `false`. | | `type` | `string` | Dialect-specific index type; see [Index Types](#index-types) for which engine takes which. | | `where` | predicate | Partial-index predicate: a `$where`, or a callback returning `raw`. See [Partial Indexes](#partial-indexes). | | `include` | callback | Non-key columns stored in the index, `(user) => [user.name]`, so a query reading only these is answered from the index alone (`INCLUDE`). Postgres and CockroachDB. | | `distance` | `string` | Distance metric (e.g., `'cosine'`, `'l2'`), mapped to the operator class. **Required** for `'hnsw'`, `'ivfflat'` and `'vector'` index types. | | `m` | `number` | HNSW: max connections per node. | | `efConstruction` | `number` | Build-time candidate list: HNSW’s `ef_construction`, CockroachDB’s `build_beam_size`, libSQL’s `insert_l`. | | `config` | `string` | `fulltext`: the language it stems in (`'english'`), `'simple'` for none, which is the default. PostgreSQL, CockroachDB and MongoDB. | | `lists` | `number` | IVFFlat: number of inverted lists. | ### Index Types | Engine | `type` values it takes | | - | - | | PostgreSQL | `btree`, `hash`, `gin`, `gist`, `brin`, `hnsw`, `ivfflat`, `fulltext` | | CockroachDB | `btree`, `gin`, `gist`, `vector`, `hnsw` (both its vector index), `fulltext` | | MySQL | `btree`, `hash`, `fulltext` | | MariaDB | `btree`, `hash`, `fulltext`, `vector` | | MSSQL | `btree` | | SQLite | any: its `CREATE INDEX` has no `USING`, so each builds a plain index | Any other type throws when the migration is generated, naming the index, instead of emitting DDL the server rejects. SQLite is the exception on purpose: an entity written for Postgres migrates there unchanged, without the specialised index. ### Per-Column Options Each entry the callback returns is a member (`note.email`), `raw` reading the members for an expression, or an object when it needs more: ```ts import { Entity, Field, Id, Index, type Json, raw } from 'uql-orm'; // Keyset pagination: the stored order is what lets `ORDER BY "createdAt" DESC` use the index. @Index((note) => [note.tenantId, { column: note.createdAt, order: 'desc' }]) // Case-insensitive uniqueness, without a duplicate lowercase column to keep in sync. @Index((note) => [raw`lower(${note.email})`], { unique: true }) // MySQL and MariaDB *require* a prefix length to index a TEXT column at all. @Index((note) => [{ column: note.body, length: 64 }]) // A smaller, faster GIN index for JSONB containment. @Index((note) => [{ column: note.data, opsClass: 'jsonb_path_ops' }], { type: 'gin', }) @Entity() export class Note { @Id({ type: Number }) id?: number; @Field({ type: String }) tenantId?: string | null; @Field({ type: 'timestamptz' }) createdAt?: Date | null; @Field({ type: String }) email?: string | null; @Field({ type: 'text' }) body?: string | null; @Field({ type: 'jsonb' }) data?: Json<{ source?: string }> | null; } ``` | Option | Description | Supported on | | - | - | - | | `column` | The member (`note.body`), or `raw` for an expression. | expressions: all but MariaDB and MSSQL | | `order` | `'asc'` (default) or `'desc'`, the order stored in the index. | all | | `length` | Index only the first *n* characters. Required for `TEXT`/`BLOB` on MySQL. | MySQL, MariaDB | | `nulls` | `'first'` or `'last'`, where NULLs sort. | Postgres | | `opsClass` | Operator class, e.g. `jsonb_path_ops`. | Postgres | | `weight` | `fulltext`: how many times a match in this column counts when ranking. | all with full-text search | | `jsonPath` | Index one path inside a JSON column: `{ path, type, length? }`. | Postgres, CockroachDB, SQLite, MySQL | | `jsonArray` | Index each element of a JSON array: `{ type, length?, path? }`. | MySQL | An option the engine cannot express throws when the migration is generated, naming the index, since the server would reject it anyway. On MongoDB, `order` maps to `1`/`-1` and `type: 'fulltext'` creates the `text` index [`$text`](https://uql-orm.dev/querying/full-text.md) needs; the SQL-only options are refused. ### JSON Indexes A path inside a JSON column is indexed with `jsonPath`, the elements of a JSON array with `jsonArray`. Both compile to the expression the query compares, which is what an engine matches an expression index by; a hand-written one that spells the path differently is never used. On MySQL a string path is keyed as `CHAR(length)`, so it needs a `length`, and a boolean path cannot be indexed at all. `jsonArray` serves `$all`, and an `$elemMatch` that asks for one string or number, or one of several. ```ts import { Entity, Field, Id, Index, type Json } from 'uql-orm'; // 'settings.theme.color': 'red' @Index((account) => [ { column: account.settings, jsonPath: { path: 'theme.color', type: String } }, ]) // tags: { $all: ['admin'] } @Index((account) => [ { column: account.tags, jsonArray: { type: String, length: 64 } }, ]) @Entity() export class Account { @Id({ type: Number }) id?: number; @Field({ type: 'jsonb' }) settings?: Json<{ theme: { color: string } }> | null; @Field({ type: 'jsonb' }) tags?: Json | null; } ``` `type` is how the value is read (a number compared as a number has to be indexed as one), and `path` is checked against the column’s payload, however deep, so a typo is a compile error: ```ts // the path is checked against the entity, so the decorator needs one to check against @Index((account) => [ { column: account.settings, jsonPath: { path: 'theme.colour', type: String }, // error: settings has no theme.colour }, ]) @Entity() class Account { @Id({ type: Number }) id?: number; @Field({ type: 'jsonb' }) settings?: Json<{ theme: { color: string }; }> | null; } ``` ### Unique Composite Index One email per tenant, rather than one email overall: ```ts @Index((user) => [user.email, user.tenantId], { unique: true }) @Entity() export class User { @Id({ type: Number }) id?: number; @Field({ type: String }) email?: string | null; @Field({ type: String }) tenantId?: string | null; } ``` ### Partial Indexes Partial indexes cover only the rows matching a `WHERE` predicate, so they stay small and queries that match that predicate hit them directly. Useful for entities with [Soft-Delete](https://uql-orm.dev/entities/soft-delete.md), where only active rows need indexing. ```ts // Index only active (non-deleted) emails to ensure uniqueness // while allowing multiple 'deleted' records with the same email. @Index((user) => [user.email], { unique: true, where: { deletedAt: null }, }) @Entity() export class User { @Id({ type: Number }) id?: number; @Field({ type: String }) email?: string | null; @Field({ type: Date, softDelete: true }) deletedAt?: Date | null; } ``` `where` takes the same predicate as `$where`, checked against the entity. The entity’s own filters are left out of it, or soft-delete would add itself to the very index meant to cover live rows. For what a predicate cannot express, pass a callback returning [`raw`](https://uql-orm.dev/querying/raw-sql.md) and read each column off its refs: ``where: (user) => raw`${user.deletedAt} IS NULL` ``. A `$where` predicate compiles through the same code as the query’s, so the two match by construction; `raw` is on you. A planner matches a partial index by the shape of the predicate, not its meaning: ``raw`${user.archived} IS NOT TRUE` ``never covers `{ archived: { $ne: true } }`, which compiles to `IS DISTINCT FROM true`. `CREATE INDEX` has no placeholder to bind a value into, so values are written as literals: ```ts @Index((product) => [product.name], { where: { stock: { $gt: 0 } } }) // WHERE "stock" > 0 ``` > **Engine support** > > Postgres, CockroachDB, SQLite, SQL Server and MongoDB take a partial index. MySQL and MariaDB have none, so `where` throws there when the migration is generated: widening a partial unique index to the whole table would reject rows the entity means to allow. > > Two engines take less of a predicate than a query does, and the rest throws the same way, naming the operator: > > - **SQL Server** takes comparisons, `$in`, `$isNull` and `$isNotNull`, joined by `AND`: no `$or`, `$not`, `$nin`, `$between` or string matching. A `raw` predicate is left to the server. > - **MongoDB** takes equality, `$gt`, `$gte`, `$lt`, `$lte`, `$between`, `$in`, `$and` and `$or` as its `partialFilterExpression`: no `null`, `$ne` or `$nin`, so `{ deletedAt: null }` has no MongoDB form. A `raw` predicate and a `Date` value throw too, since a migration carries the filter as JSON. ## Full-Text Indexes A `fulltext` index is what [`$text`](https://uql-orm.dev/querying/full-text.md) searches, and one declaration serves every engine that has full-text search. A column’s `weight` (a whole number from 1 to 99999, 1 by default) says how much a match in it counts when `$sort: { $text }` ranks, and `config` the language: ```ts import { Entity, Field, Id, Index } from 'uql-orm'; @Index( (listing) => [{ column: listing.title, weight: 3 }, listing.description], { type: 'fulltext', config: 'spanish', }, ) @Entity() export class Listing { @Id({ type: Number }) id?: number; @Field({ type: String }) title?: string | null; @Field({ type: String }) description?: string | null; } ``` It builds a `GIN` index on PostgreSQL and CockroachDB, a `FULLTEXT` index on MySQL and MariaDB (plus one for each column heavier than the lightest, which scores it), and a `text` index with its `weights` and `default_language` on MongoDB. A weight on any other index type throws. See [column weights](https://uql-orm.dev/querying/full-text.md#column-weights) for what each engine does with them. ## Vector Indexes Vector indexes, for [semantic search](https://uql-orm.dev/querying/semantic-search.md), use the same decorator with the vector options above. `distance` is required on a vector index and rejected on any other kind. Both directions are compile errors, because the DDL would otherwise be silently wrong: MariaDB’s `DISTANCE=` defaults to euclidean, so a cosine query would full-scan. Match the metric your queries sort by. ```ts @Index((article) => [article.embedding], { type: 'hnsw' }) // error: missing distance @Index((article) => [article.embedding], { type: 'btree', distance: 'cosine' }) // error: distance on a non-vector index ``` ```ts @Index((article) => [article.embedding], { type: 'hnsw', distance: 'cosine', m: 16, efConstruction: 64, }) @Entity() export class Article { @Id({ type: Number }) id?: number; @Field({ type: 'vector', dimensions: 1536 }) embedding?: number[] | null; } ``` [Migrations](https://uql-orm.dev/migrations.md) track these parameters: if you tune `m` or `efConstruction` in code, the diff includes the `DROP`/`CREATE` needed to rebuild the index. Which engine takes which vector index, CockroachDB’s and MariaDB’s native `type: 'vector'` included, is on [Semantic Search](https://uql-orm.dev/querying/semantic-search.md#vector-indexes). ## Synchronization Indexes travel both ways through [migrations](https://uql-orm.dev/migrations.md): adding or removing a decorator shows up in `generate:entities` and `sync`, and `generate:from-db` writes the indexes it finds back as `@Field({ index })` or `@Index()`. # Enum + Checks > Enum fields and table-level CHECK constraints, so the database enforces what the entity declares. Source: https://uql-orm.dev/entities/enum-checks A column’s type says what shape a value has. A constraint says which values are allowed. Both are declared on the entity and emitted with the table. ## Enum fields `enum` states the values a column accepts. The database enforces them with a `CHECK (col IN (...))` and TypeScript checks the property against the same set, so a property admitting a value the column would reject is a compile error: ```ts import { Entity, Field, Id } from 'uql-orm'; @Entity() export class Invoice { @Id({ type: Number }) id?: number; @Field({ type: String, enum: ['draft', 'paid', 'void'] as const }) status?: 'draft' | 'paid' | 'void' | null; } ``` `as const` is what makes any of it check. Without it the values widen to `string[]` and the property is narrowed to `__enumNeedsAsConst`, a type nothing can hold, whose name is the error you get. Strings and numbers only, each escaped by the dialect’s own literal rules, so a number stays bare where a string is quoted. A check rather than PostgreSQL’s `CREATE TYPE ... AS ENUM`: one declaration works on every dialect, and changing the set stays an ordinary column change instead of an irreversible `ALTER TYPE ... ADD VALUE` or a rewrite of every dependent column. ### TypeScript enums A string enum works in place of the literal array, and needs no `as const`, since its members already infer narrower than `string`: ```ts enum Status { Draft = 'draft', Paid = 'paid', Void = 'void', } @Entity() export class Invoice { @Id({ type: Number }) id?: number; @Field({ type: String, enum: Object.values(Status) }) status?: Status | null; } ``` The check is the same `CHECK ("status" IN ('draft', 'paid', 'void'))`, with the values, never the member names. The property has to be typed `Status` though: a TS enum is nominal, so the equivalent literal union is not assignable to it. Numeric enums do not work. Their members are assignable from any `number`, so they narrow nothing and the guard fires, and `Object.values` on one yields the reverse-mapped names alongside the numbers. Write those values as literals instead: `enum: [0, 1] as const` with a `0 | 1` property. **The values are fixed when the table is created.** A check is never diffed, so adding one later emits no statement and the column goes on rejecting it, silently. Widen it by hand; the constraint carries the name the engine gave it: ```sql title="PostgreSQL" ALTER TABLE "invoice" DROP CONSTRAINT "invoice_status_check"; ALTER TABLE "invoice" ADD CONSTRAINT "invoice_status_check" CHECK ("status" IN ('draft', 'paid', 'void')); ``` ## Check constraints A condition over the whole row, declared on the entity. Write it as a predicate, the same `$where` a query takes, or, for what a predicate cannot express, as a callback returning [`raw`](https://uql-orm.dev/querying/raw-sql.md) that reads each column off its refs. Either way, columns are quoted for the engine and follow a rename. ```ts import { Entity, Field, Id, raw } from 'uql-orm'; @Entity({ checks: [ { name: 'wallet_non_negative_ck', where: { creditsBalance: { $gte: 0 } } }, { where: (wallet) => raw`${wallet.spent} <= ${wallet.creditsBalance}` }, ], }) export class Wallet { @Id({ type: Number }) id?: number; @Field({ type: Number }) creditsBalance?: number | null; @Field({ type: Number }) spent?: number | null; } ``` An unnamed check is named `
___ck` by declaration order, the same rule that names an unnamed index or foreign key. `CREATE TABLE` has no placeholder to bind a value into, so values are written as literals: `{ where: { stock: { $gt: 0 } } }` emits `CHECK ("stock" > 0)`. [Partial-index predicates](https://uql-orm.dev/entities/indexes.md#partial-indexes) work the same way. Constraining a single column to a set of values is [`enum`](#enum-fields) instead, which writes the check for you. > **Created with the table** > > A check is emitted by `CREATE TABLE`. Adding or changing one on a table that already exists is a hand-written migration. > > A check is only SQL text, reprinted from the database’s parse tree (PostgreSQL reads `CHECK ("balance" >= 0)` back as `CHECK ((balance >= (0)::numeric))`), so a diff could match names but never contents, and only PostgreSQL reports checks at all. [Indexes](https://uql-orm.dev/entities/indexes.md) skip a partial index’s predicate for the same reason. # Inheritance > Share fields across entities with abstract base classes and inheritance in UQL. Source: https://uql-orm.dev/entities/inheritance ## Inheritance between entities Fields and relations are inherited from a parent class, abstract or concrete, so shared columns are declared once. ### Base Entity Pattern A common pattern is to define a base class with common fields like `id`, `createdAt`, and `updatedAt`. ```ts import { v7 as uuidv7 } from 'uuid'; import { Entity, Id, Field } from 'uql-orm'; /** * An abstract class for shared audit fields. */ export abstract class BaseEntity { @Id({ type: String, onInsert: uuidv7 }) id?: string; @Field({ type: 'timestamptz', onInsert: () => new Date() }) createdAt?: Date | null; @Field({ type: 'timestamptz', onUpdate: () => new Date() }) updatedAt?: Date | null; } /** * 'Company' inherits all fields from 'BaseEntity'. */ @Entity() export class Company extends BaseEntity { @Field({ type: String, length: 150 }) name?: string | null; @Field({ type: 'text' }) description?: string | null; } ``` ### Specifying Custom Metadata You can customize the entity name and field properties in the child classes. ```ts import { Entity, Field, OneToOne } from 'uql-orm'; /** * You can also specify a custom table name. */ @Entity({ name: 'user_profile' }) export class Profile extends BaseEntity { @Field({ name: 'image', type: 'text' }) picture?: string | null; @Field({ type: String, references: () => User }) userId?: string | null; @OneToOne({ entity: () => User, references: (profile) => profile.userId }) user?: User; } ``` ```ts @Entity() export class User extends BaseEntity { @Field({ type: String }) name?: string | null; @Field({ type: String, updatable: false }) email?: string | null; @Field({ type: String, eager: false }) password?: string | null; @OneToOne({ entity: () => Profile, mappedBy: (profile) => profile.user, cascade: true, }) profile?: Profile; } ``` ### Naming a base you cannot extend A class minted at runtime has no base to extend, and one whose base is chosen from data cannot name it in its `extends` clause. `defineEntity({ extends: Base })` says the same thing in the options: ```ts import { defineEntity, defineField } from 'uql-orm'; /** The audit columns every entity carries. Never an entity itself: it has no key of its own. */ class Audited { createdBy?: string; } defineField(Audited, 'createdBy', { type: String }); // Minted the way a runtime schema mints one, so there was never an `extends` clause to write. const Faq = { faq: class { id!: number; question?: string | null; createdBy?: string; }, }['faq']; defineEntity(Faq, { extends: Audited, fields: { id: { type: 'bigint', isId: true }, question: { type: 'text' } }, }); ``` The merge is the one `extends` on the class does: the base’s own ancestors come too, the child’s own members win, and a child declaring its own key replaces the base’s. The base needs no `defineEntity` of its own, as an abstract decorated base needs no `@Entity()`; give it one only where it is also a table. Where a class both extends a base and names one, what it really extends is the nearer of the two. The base’s properties are checked against the entity’s own, as `extends` on the class checks them, so a base declaring `createdBy?: number` here is a compile error. A class that declares no columns at all, a minted one behind an index signature, has nothing to check and takes any base. ### Replacing an inherited key A subclass that declares its own `@Id` has to name it with the `idKey` brand: the key inherited from the base is still called `id`, and that is the name the type level would otherwise read as the key. ```ts import { Entity, Id, Field, idKey } from 'uql-orm'; @Entity() export class TaxCategory extends BaseEntity { [idKey]?: 'pk'; @Id({ type: String, onInsert: uuidv7 }) pk?: string; @Field({ type: String }) name?: string | null; } ``` A subclass declaring its own `@Id` replaces the parent’s key entirely (every column of it, where the parent’s was [composite](https://uql-orm.dev/entities/basic.md#composite-primary-keys)) and keeps every other inherited field. Inherited fields are typed and queried like the class’s own, so changing a base field propagates to every child entity. # Example Entities > The shared entities every query example in this section imports, and the fields those examples select, filter and sort on. Source: https://uql-orm.dev/querying/models Every example in this section queries the entities below, imported from one shared module. The `$select`, `$where`, `$sort` and `$populate` keys you see on those pages are the fields and relations of these classes, so this is the page to keep open beside the others. ```ts title="shared/models/index.ts" import { Entity, Field, Id, ManyToMany, ManyToOne, OneToMany, OneToOne, } from 'uql-orm'; @Entity() export class Company { @Id({ type: Number }) id?: number; @Field({ type: String }) name?: string | null; @Field({ type: String }) country?: string | null; @Field({ type: Date }) createdAt?: Date | null; } /** A lookup table, soft-deletable, which is why the joins below carry `deletedAt IS NULL`. */ @Entity() export class Tax { @Id({ type: Number }) id?: number; @Field({ type: String }) name?: string | null; @Field({ type: Date, softDelete: true }) deletedAt?: Date | null; } @Entity() export class MeasureUnitCategory { @Id({ type: Number }) id?: number; @Field({ type: String }) name?: string | null; @OneToMany({ entity: () => MeasureUnit, mappedBy: (measureUnit) => measureUnit.category, }) measureUnits?: MeasureUnit[]; } @Entity() export class MeasureUnit { @Id({ type: Number }) id?: number; @Field({ type: String }) name?: string | null; @Field({ references: () => MeasureUnitCategory }) categoryId?: number | null; @ManyToOne({ entity: () => MeasureUnitCategory, references: (measureUnit) => measureUnit.categoryId, }) category?: MeasureUnitCategory; @Field({ type: Date, softDelete: true }) deletedAt?: Date | null; } @Entity() export class Profile { @Id({ type: Number }) id?: number; @Field({ type: String }) picture?: string | null; @Field({ type: String }) bio?: string | null; @Field({ references: () => User }) userId?: number | null; @OneToOne({ entity: () => User, references: (profile) => profile.userId }) user?: User; } @Entity() export class User { @Id({ type: Number }) id?: number; @Field({ type: String, unique: true }) email?: string | null; @Field({ type: String }) name?: string | null; @Field({ type: String }) password?: string | null; /** `'active'`, `'pending'`, `'banned'`. */ @Field({ type: String }) status?: string | null; @Field({ type: Number }) age?: number | null; @Field({ type: Boolean }) active?: boolean | null; @Field({ type: Date }) createdAt?: Date | null; @Field({ type: Number }) creatorId?: number | null; @Field({ references: () => Company }) companyId?: number | null; @ManyToOne({ entity: () => Company, references: (user) => user.companyId }) company?: Company; @OneToOne({ entity: () => Profile, mappedBy: (profile) => profile.user, cascade: true, }) profile?: Profile; @OneToMany({ entity: () => Post, mappedBy: (post) => post.author }) posts?: Post[]; } @Entity() export class Post { @Id({ type: Number }) id?: number; @Field({ type: String }) title?: string | null; @Field({ type: String }) body?: string | null; @Field({ type: Boolean }) published?: boolean | null; /** Upvotes, which the [raw projection](/querying/querier#raw-projections-in-select) example ranks on. */ @Field({ type: Number }) points?: number | null; @Field({ type: Date }) createdAt?: Date | null; @Field({ references: () => User }) authorId?: number | null; @Field({ references: () => User }) creatorId?: number | null; @ManyToOne({ entity: () => User, references: (post) => post.authorId }) author?: User; } @Entity() export class Item { @Id({ type: Number }) id?: number; @Field({ type: String }) name?: string | null; @Field({ type: String }) description?: string | null; @Field({ type: String }) code?: string | null; @Field({ type: Number }) price?: number | null; /** What the [row-locking](/querying/locking) examples decrement under a lock. */ @Field({ type: Number }) stock?: number | null; @Field({ type: Boolean }) isActive?: boolean | null; @Field({ type: Date }) createdAt?: Date | null; @Field({ type: 'vector', dimensions: 1536 }) embedding?: number[] | null; @Field({ references: () => Company }) companyId?: number | null; @ManyToOne({ entity: () => Company, references: (item) => item.companyId }) company?: Company; @Field({ references: () => Tax }) taxId?: number | null; @ManyToOne({ entity: () => Tax, references: (item) => item.taxId }) tax?: Tax; @Field({ references: () => MeasureUnit }) measureUnitId?: number | null; @ManyToOne({ entity: () => MeasureUnit, references: (item) => item.measureUnitId, }) measureUnit?: MeasureUnit; /** What the [relation `$size`](/querying/relations) and [`$count`](/querying/counting) examples tally. */ @ManyToMany({ entity: () => Tag, through: () => ItemTag }) tags?: Tag[]; } @Entity() export class Tag { @Id({ type: Number }) id?: number; @Field({ type: String }) name?: string | null; } /** The junction `Item.tags` counts through. */ @Entity() export class ItemTag { @Id({ type: Number }) id?: number; @Field({ references: () => Item }) itemId?: number | null; @Field({ references: () => Tag }) tagId?: number | null; } /** What the [transaction](/querying/transactions) examples debit, so they have a balance to get wrong. */ @Entity() export class Account { @Id({ type: Number }) id?: number; @Field({ type: Number }) balance?: number | null; @Field({ references: () => Company }) companyId?: number | null; } /** What the [aggregate](/querying/aggregate) examples group and total. */ @Entity() export class Order { @Id({ type: Number }) id?: number; /** `'pending'`, `'shipped'`, `'cancelled'`. */ @Field({ type: String }) status?: string | null; @Field({ type: Number }) amount?: number | null; @Field({ type: Number }) customerId?: number | null; @Field({ type: Date }) createdAt?: Date | null; } @Entity() export class Invoice { @Id({ type: Number }) id?: number; @Field({ type: Number }) amount?: number | null; @Field({ type: Number }) total?: number | null; @Field({ type: Boolean }) paid?: boolean | null; @Field({ type: Date }) createdAt?: Date | null; @Field({ references: () => Company }) companyId?: number | null; } ``` # Querier > Learn how to use the querier to interact with any database through UQL. Source: https://uql-orm.dev/querying/querier A `querier` holds one connection and runs UQL’s query methods on it, for any entity on any database. ## Using a Querier The query methods live on the [pool](https://uql-orm.dev/pool.md). For a **single operation**, call one straight on the pool and the connection is acquired and released for you. For a **unit of work** (several statements that share a connection, or must commit together), use `pool.withQuerier()` / `pool.transaction()` and call the methods on the `querier` it hands you. Same methods, two entry points: [which to use, and why](#choosing-poolx-vs-querierx). ```ts title="You write" import { pool } from './uql.config.js'; import { User } from './shared/models/index.js'; const users = await pool.findMany(User, { $select: { id: true, name: true }, // Whitelist scalar fields $populate: { profile: true }, // Load relations $where: { $or: [{ name: 'roger' }, { creatorId: 1 }], }, $sort: { createdAt: 'desc' }, $limit: 10, }); ``` PostgreSQL: ```sql SELECT "User"."id", "User"."name", -- $populate fields from joined relations "profile"."id" "profile.id", "profile"."picture" "profile.picture" FROM "User" LEFT JOIN "Profile" "profile" ON "profile"."userId" = "User"."id" WHERE "User"."name" = $1 OR "User"."creatorId" = $2 ORDER BY "User"."createdAt" DESC LIMIT 10 ``` Pool calls also let you **release the connection before slow non-DB work** (an external API or an LLM), so the pool is not starved: ```ts import { Item } from './shared/models/index.js'; // Phase 1: read from DB (single read - the pool one-liner acquires and releases for you) const item = await pool.findOne(Item, { $where: { id: itemId } }); // Phase 2: slow external call (no connection held) const description = await callExternalApi(item); // Phase 3: write the result back, on a connection of its own again await pool.updateOneById(Item, itemId, { description }); ``` ### The result is the row you asked for A projection shapes the result type, so reading something the query never fetched is a compile error rather than a silent `undefined`: ```ts const [user] = await pool.findMany(User, { $select: { id: true, name: true }, $populate: { profile: true }, }); user.name; // string user.profile; // Profile, because the query populated it user.password; // error: not selected user.posts; // error: not populated ``` `$select: { password: false }` and `$exclude` subtract instead, and a joined row keeps its id, so the type follows what the statement actually returns. A query that projects nothing gives you the whole entity, as does one whose projection is not known statically: a [raw projection](#raw-projections-in-select), or a query built elsewhere and annotated as `Query`. Every clause is still checked key by key: a typo in `$select`, `$where`, `$sort`, `$populate`, or inside a populated relation’s own query, fails to compile. #### Naming a projected row A narrowed row is not the entity, so a helper typed `(user: User) => ...` will not take one. Name the shape with `QueryFindResult` instead of widening the query: ```ts import type { QueryFindResult } from 'uql-orm'; type UserCard = QueryFindResult; function render(user: UserCard) { return `${user.id} ${user.name}`; } ``` The field names come second. A third parameter takes the map’s value (`false` for the subtractive form, `QueryFindResult`), and a fourth and fifth take `$exclude`’s field names and `$populate`’s relation names. ### Subtractive projection with `$exclude` When you want every scalar column *except* a few, use `$exclude` instead of listing the rest by hand: ```ts title="You write" const users = await pool.findMany(User, { $exclude: { password: true }, $populate: { profile: true }, }); ``` `$exclude` is mutually exclusive with a positive `$select`: combining `$select: { name: true }` with `$exclude` throws a `TypeError`, at every level of `$populate`. `$select: { password: false }` is the equivalent shorthand. A joined (to-one) row keeps its primary key under any subtraction, since that key alone tells a match from none. Every other key, the parent’s included, is subtracted like any other column. ### Raw projections in `$select` For plain column selection use the object form (`{ id: true }`). When you need a computed column, `$select` also accepts an array of [`raw()`](https://uql-orm.dev/querying/sub-queries.md#understanding-raw) expressions (SQL dialects only), each with an optional alias that becomes the result key: ```ts title="You write" import { raw } from 'uql-orm'; import { Post } from './shared/models/index.js'; const posts = await pool.findMany(Post, { $select: [ raw`*`, raw`LOG10("points" + 1) * 287014.58 + "createdAt"`.as('hotness'), ], $sort: { createdAt: 'desc' }, }); ``` PostgreSQL: ```sql SELECT *, LOG10("points" + 1) * 287014.58 + "createdAt" AS "hotness" FROM "Post" ORDER BY "Post"."createdAt" DESC ``` The object and array forms are mutually exclusive, and MongoDB rejects the array form. A populated relation takes it too, each expression aliased with `.as()`. ## The same query, every transport A UQL query is a plain object, so the *same* value works on every layer, with no DTO or second schema, and the result stays typed everywhere, populated relations included. `WireQuery` is the one that travels: `Query` without [`raw`](https://uql-orm.dev/querying/raw-sql.md) SQL, which JSON cannot carry. ```ts title="Define it once" import type { WireQuery } from 'uql-orm/type'; import { User } from './shared/models/index.js'; // filters, sorting, and nested relation loading, all type-checked against User const query: WireQuery = { $select: { id: true, name: true }, $where: { status: 'active' }, $populate: { posts: { $select: { title: true }, $where: { published: true }, $limit: 5 }, }, $sort: { createdAt: 'desc' }, $limit: 10, }; ``` ```ts title="Use it everywhere" // 1. On the server: straight on the pool (or a querier) const onServer = await pool.findMany(User, query); // 2. From the browser: against your REST API, same object and same types const { data: inBrowser } = await httpQuerier.findMany(User, query); // 3. Across an RPC boundary (tRPC / oRPC): it travels as JSON, untouched const overRpc = await trpc.user.findMany.query(query); ``` See the [HTTP core](https://uql-orm.dev/http.md), [browser client](https://uql-orm.dev/browser.md), and the [tRPC](https://uql-orm.dev/trpc.md) / [oRPC](https://uql-orm.dev/orpc.md) recipes. ## Manual Querier Management When the connection has to outlive a single callback, take it yourself with `pool.getQuerier()` and bind it with `await using`, which releases it when the block exits however it exits: ```ts import { User } from './entities/index.js'; import { pool } from './uql.config.js'; async function report(companyId: number) { await using querier = await pool.getQuerier(); const users = await querier.findMany(User, { $where: { companyId }, $limit: 10, }); if (!users.length) { return null; // released here too, with no finally to remember } return { users, total: await querier.count(User, { $where: { companyId } }) }; } ``` An unreleased connection is the one leak a pool cannot recover from, and `await using` releases it even on an early return or a throw. Where the syntax is unavailable, use `try` / `finally` with `await querier.release()`. --- Every method, its arguments and what each database reports back is on the [methods reference](https://uql-orm.dev/querying/methods.md). ## Choosing: `pool.x` vs. `querier.x` `pool.findMany(User, q)` is exactly `pool.withQuerier((querier) => querier.findMany(User, q))`, and the same holds for every other operation: the pool runs a **single operation** as its own unit of work (acquire a connection, run, release). A `querier` is the handle you get **inside** a `withQuerier` / `transaction` callback, where several operations share one connection. | You’re running… | Use | Why | | - | - | - | | A single operation | `pool.findMany` / `insertOne` / `updateMany` / … (or [`pool.all`](https://uql-orm.dev/querying/raw-sql.md#raw-sql-on-the-pool) for raw SQL) | Connection acquired and released per call, so `Promise.all` runs them on separate connections in parallel | | Several operations that belong together | `pool.withQuerier((querier) => …)` | One pinned connection for all of them | | Work that must be all-or-nothing | `pool.transaction((querier) => …)` | Same pinned connection, plus begin / commit / rollback | Two pool calls are two units of work, so nothing rolls the first one back if the second fails. When they have to commit together, that is a `transaction`. Independent reads on the pool run in parallel; the same calls inside one `withQuerier` share a pinned connection and queue: ```ts title="Two connections, in parallel" import { Invoice } from './shared/models/index.js'; const [invoices, total] = await Promise.all([ pool.findMany(Invoice, { $where: { paid: false } }), pool.count(Invoice, {}), ]); ``` ```ts title="One pinned connection, queries serialize" await pool.withQuerier((querier) => Promise.all([querier.findMany(Invoice, {}), querier.count(Invoice, {})]), ); ``` An enclosing [`withContext`](https://uql-orm.dev/multi-tenancy.md) scopes pool calls like any other query, so one wrapper covers a whole parallel fan-out: ```ts import { withContext } from 'uql-orm'; await withContext({ tenantId }, () => Promise.all([pool.findMany(Invoice, {}), pool.count(Invoice, {})]), ); ``` > **Where pool calls differ** > > - Pool calls take the entity-as-argument form only; for the `{ $entity }` form, use `withQuerier`. > - [`pool.findManyStream`](https://uql-orm.dev/querying/streaming.md) is the exception to acquire/run/release: it holds its connection until the loop ends, so consume it in a `for await` rather than abandoning the iterator. > - On single-connection backends (better-sqlite3, Bun sqlite, D1) pool calls stay correct but share the one connection, so they serialize rather than parallelize. ## Accept a `UniversalQuerier` `Querier` and `QuerierPool` implement the same interface, `UniversalQuerier`. A function that needs somewhere to run its queries takes that type, and the caller decides what it runs on: ```ts import type { UniversalQuerier } from 'uql-orm'; import { Invoice } from './shared/models/index.js'; async function raiseInvoice(db: UniversalQuerier, invoice: Invoice) { await db.insertOne(Invoice, invoice); } ``` ```ts const invoice: Invoice = { companyId: 42, total: 1200 }; await raiseInvoice(pool, invoice); // its own unit of work await pool.transaction((querier) => raiseInvoice(querier, invoice)); // joins the caller's ``` That makes helpers composable: each works on its own, and the caller can make any group of them atomic without touching them: ```ts async function settleOutstanding(db: UniversalQuerier, companyId: number) { return db.updateMany( Invoice, { $where: { companyId, paid: false } }, { paid: true }, ); } // Either both land or neither does, and neither function knows about the other. await pool.transaction(async (querier) => { await settleOutstanding(querier, 42); await raiseInvoice(querier, invoice); }); ``` Hardcode `pool` inside those functions instead and that last guarantee is gone: each call becomes its own unit of work, so a failure halfway through leaves the first write committed. Hardcode `Querier` and every caller has to open a unit of work even when it only wants one statement. > **Which of the two types to take** > > The type is the whole contract. Take `UniversalQuerier` when the function runs queries, and `Querier` only when it genuinely needs a pinned connection: to open a transaction itself, to use the `{ $entity }` form, or to hold `all`/`run` and a query on one connection. ## Next Steps - [Comparison Operators](https://uql-orm.dev/querying/comparison-operators.md): Everything you can put in `$where`. - [Deep Relations](https://uql-orm.dev/querying/relations.md): `$populate`, relation filters, and relation sorting. - [Transactions](https://uql-orm.dev/querying/transactions.md): Units of work, isolation levels, and nesting. - [Streaming](https://uql-orm.dev/querying/streaming.md): Row-by-row iteration for large result sets. # Methods Reference > Every querier and pool method, the IDs an insert reports back per database, and the upsert operations. Source: https://uql-orm.dev/querying/methods ## Available Methods Every data method is on the [querier](https://uql-orm.dev/querying/querier.md) and on the pool, same name and arguments. The querier runs it on the connection you are holding; the pool acquires one for that call and releases it ([which to use](https://uql-orm.dev/querying/querier.md#choosing-poolx-vs-querierx)). Only the last four are the querier’s alone, because they are what owning a connection means. | Method | Description | | - | - | | `findMany(Entity, query, opts?)` | Find multiple records matching the query. | | `findManyStream(Entity, query, opts?)` | [Stream records](https://uql-orm.dev/querying/streaming.md) as an `AsyncIterable`, row by row, each with the relations `findMany` would load. | | `findManyAndCount(Entity, query, opts?)` | Find records and return `[rows, totalCount]` - the page, and how many matched beyond it. | | `findOne(Entity, query, opts?)` | Find a single record matching the query. | | `findOneById(Entity, id, query?, opts?)` | Find a record by its primary key. | | `count(Entity, query?, opts?)` | [Count records](https://uql-orm.dev/querying/counting.md) matching the query. A `$skip`/`$limit` counts that page instead of every match. | | `exists(Entity, query?, opts?)` | [Whether anything matches](https://uql-orm.dev/querying/counting.md#exists), stopping at the first row. | | `estimatedCount(Entity)` | [The engine’s own row estimate](https://uql-orm.dev/querying/counting.md#estimatedcount), read from its statistics without scanning. Approximate, whole-table, server-side only. | | `aggregate(Entity, query, opts?)` | Run an [aggregate query](https://uql-orm.dev/querying/aggregate.md) (`GROUP BY`, `HAVING`, etc.). | | `insertOne(Entity, data)` | Insert a single record and return its ID. | | `insertMany(Entity, data[])` | Insert multiple records and return their IDs. | | `updateOneById(Entity, id, data, opts?)` | Update a record by its primary key. | | `updateMany(Entity, query, data, opts?)` | Update multiple records matching the query. One naming no rows (no `$where`, no `$limit`) throws; pass `{ unfiltered: true }` to mean the whole table. | | `saveOne(Entity, data)` | Insert, or upsert on the primary key when the payload names it. Returns the ID. | | `saveMany(Entity, data[])` | Bulk insert/upsert, per row, on the same rule. Returns the IDs in payload order. | | `upsertOne(Entity, conflictPaths, data)` | Insert or update on the conflict paths. Returns `{ id, changes, created }`. | | `upsertMany(Entity, conflictPaths, data[])` | Bulk insert or update on the conflict paths. Returns `{ ids, changes }`, IDs in payload order. | | `deleteOneById(Entity, id, opts?)` | Delete by primary key. [Soft-deletes](https://uql-orm.dev/entities/soft-delete.md) when the entity has a soft-delete field; pass `{ hardDelete: true }` to remove permanently. | | `deleteMany(Entity, query, opts?)` | Delete multiple records matching the query (soft by default; `{ hardDelete: true }` removes permanently). Naming no rows throws, as for `updateMany`. | | `restoreOneById(Entity, id)` | Restore a [soft-deleted](https://uql-orm.dev/entities/soft-delete.md) record by its primary key. | | `restoreMany(Entity, query)` | Restore soft-deleted records matching the query. | | [`run(sql, values?)`](https://uql-orm.dev/querying/raw-sql.md) | Execute [raw SQL](https://uql-orm.dev/querying/raw-sql.md) (INSERT, UPDATE, DELETE). | | [`all(sql, values?)`](https://uql-orm.dev/querying/raw-sql.md) | Execute [raw SQL SELECT](https://uql-orm.dev/querying/raw-sql.md) with generics. | | `transaction(callback, opts?)` | Run a [transaction](https://uql-orm.dev/querying/transactions.md) within a callback. | | `beginTransaction(opts?)` | Start a [transaction](https://uql-orm.dev/querying/transactions.md) manually. | | `commitTransaction()` | Commit the active transaction. | | `rollbackTransaction()` | Roll back the active transaction. | | `release()` | Roll back any unfinished transaction and return the connection to the pool. The querier is finished afterwards: using it again throws. | The trailing `opts?` on reads, updates, and deletes is a [`QueryOptions`](https://uql-orm.dev/querying/filters.md): bypass [query filters](https://uql-orm.dev/querying/filters.md) for the call (e.g. `withDeleted()` to include soft-deleted rows, or `{ filters: false }`), or force `{ hardDelete: true }` on a delete. > **RPC-friendly form** > > A query-based method also takes the entity inside the query, so the whole call serializes as one JSON value across an RPC or REST boundary (`$entity` is stripped before execution). > > ```ts > const users = await pool.withQuerier((querier) => > querier.findMany({ $entity: User, $where: { status: 'active' } }), > ); > ``` ### Atomic arithmetic `$inc` adds to a numeric field and `$mul` multiplies it, inside the statement, so no read comes between and no concurrent write is lost. A NULL counts as 0, on every database, and a field takes one of them per update. Put the guard in `$where` and the count of changed rows tells you whether it held: ```ts const taken = await pool.updateMany( Item, { $where: { id, stock: { $gte: quantity } } }, { stock: { $inc: -quantity } }, ); if (taken === 0) throw new Error('sold out'); ``` A `bigint` field takes a `bigint` operand, which keeps it exact. A fraction needs a column that holds one (`precision`/`scale`), as any written value does. Both are plain JSON, so they also work from the [browser](https://uql-orm.dev/browser.md), where `raw` does not. JSON fields have their own [update operators](https://uql-orm.dev/querying/json.md). ### Insert IDs Every write reports its ID in one shape: the column’s value on a single key, the [key map](#composite-keys) on a composite. `insertOne`/`insertMany` return them in payload order. IDs you provide, and IDs generated client-side via `@Id({ onInsert })` (e.g. `randomUUID`), come back as-is on every database. Database-generated ones are exact per row wherever the statement itself reports them: PostgreSQL, CockroachDB, MariaDB, and SQLite (including LibSQL/Turso, Cloudflare D1, and Bun’s native SQL) via `INSERT ... RETURNING`, MSSQL via `OUTPUT INSERTED`, and MongoDB via `insertedIds`. Only MySQL (and Bun SQL’s MySQL mode) has no `RETURNING`: its driver reports one generated ID per statement, and UQL infers the rest arithmetically. That inference needs every row *in a statement* to have left the key to the database, so a mixed batch is split into one statement per kind and both halves report (MySQL detects a clustered `auto_increment_increment` stride automatically). An entry is `undefined` only where nothing could name the row: a non-auto-increment key the caller did not supply. ```ts const ids = await pool.insertMany(User, [ { name: 'Ada', email: 'ada@uql-orm.dev' }, { id: 5000, name: 'Alan' }, // explicit id, and omits email ]); // Alan's missing email falls back to its column default. // ids on every database, MySQL included: [1, 5000] ``` > **MySQL under concurrent writes** > > MySQL’s inferred IDs assume the statement got a contiguous block of auto-increment values, which only holds under `innodb_autoinc_lock_mode` 0 (`traditional`) or 1 (`consecutive`). Under mode 2 (`interleaved`, MySQL 8.0’s default), other connections inserting into the same table concurrently with your batch can interleave with its allocation, so the inferred IDs may not be contiguous. With no `RETURNING` there is no code-level fix: avoid relying on inferred multi-row IDs for a table under heavy concurrent inserts, or set `innodb_autoinc_lock_mode` to 0 or 1. Records in one `insertMany` batch may provide different subsets of columns: the statement uses the union of columns, and missing cells fall back to the database default (`DEFAULT` keyword; `NULL` on SQLite, which also triggers its auto-generated keys). Batches larger than the dialect’s bind-parameter limit are split into multiple statements automatically; wrap the call in a [transaction](https://uql-orm.dev/querying/transactions.md) if all-or-nothing behavior matters across such splits. ### saveOne / saveMany `save` picks a statement per row from whether the payload **names its primary key**, not from whether the row exists: | The row | What runs | | - | - | | Names no key | `INSERT` | | Names its key, and carries other columns | `INSERT ... ON CONFLICT DO UPDATE` on that key | | Names its key, and nothing else | nothing: a reference, not a write | That last row is how a relation links something it did not author: `{ tags: [{ id: 22 }] }` writes the junction row and leaves tag 22 untouched. A stale ID therefore writes the row instead of updating nothing. It fires `@BeforeUpsert`/`@AfterUpsert`, never the update pair ([lifecycle hooks](https://uql-orm.dev/entities/lifecycle-hooks.md)). IDs come back in payload order. ### Composite keys On an entity with a [composite primary key](https://uql-orm.dev/entities/basic.md#composite-primary-keys), every write reports that key as the map the by-id methods take. No column holds it, so no statement reports one; the row is named from the payload that wrote it. ```ts await pool.insertMany(Enrolment, [ { studentId: 1, courseId: 'maths', grade: 'A' }, ]); // [{ studentId: 1, courseId: 'maths' }] ``` `idOf(getMeta(Enrolment), row)` names a row you already hold, the same way. MongoDB refuses composite keys outright, on reads as well as writes; see [what is not supported yet](https://uql-orm.dev/entities/basic.md#what-is-not-supported-yet). --- ## Pool API The pool manages the connection lifecycle. These are the main `pool` methods: | Method | Description | | - | - | | `pool.withQuerier(callback)` | Acquire a querier, run `callback`, and auto-release, even on errors. | | `pool.transaction(callback)` | Like `withQuerier`, but wraps the callback in a transaction. | | `pool.getQuerier()` | Manually acquire a querier. **Releasing it is yours**: bind it with `await using`, or call `querier.release()` in a `finally`. Either way, an unfinished transaction is rolled back on release. | | `pool.findMany(...)` and every other operation | Run a single operation on its own connection; see [pool vs. querier](https://uql-orm.dev/querying/querier.md#choosing-poolx-vs-querierx). | | [`pool.all(sql, values?)` / `pool.run(sql, values?)`](https://uql-orm.dev/querying/raw-sql.md#raw-sql-on-the-pool) | Run one [raw SQL](https://uql-orm.dev/querying/raw-sql.md) statement on its own connection (SQL pools only). | | `pool.end()` | Gracefully shut down the pool (close all connections). | ## Upsert Operations Upsert (insert-or-update) resolves conflicts using **conflict paths**: the fields that define uniqueness. If a row with matching conflict path values already exists, it is updated; otherwise, a new row is inserted. ### `upsertOne` ```ts title="You write" await pool.upsertOne( User, { email: true }, { email: 'roger@uql-orm.dev', name: 'Roger', }, ); ``` PostgreSQL / CockroachDB: ```sql INSERT INTO "User" ("email", "name") VALUES ($1, $2) ON CONFLICT ("email") DO UPDATE SET "name" = EXCLUDED."name" ``` ### `upsertMany` Efficiently upsert multiple records in a single statement: ```ts title="You write" await pool.upsertMany(User, { email: true }, [ { email: 'roger@uql-orm.dev', name: 'Roger' }, { email: 'ana@uql-orm.dev', name: 'Ana' }, { email: 'freddy@uql-orm.dev', name: 'Freddy' }, ]); ``` PostgreSQL: ```sql INSERT INTO "User" ("email", "name") VALUES ($1, $2), ($3, $4), ($5, $6) ON CONFLICT ("email") DO UPDATE SET "name" = EXCLUDED."name" ``` MySQL: ```sql INSERT INTO `User` (`email`, `name`) VALUES (?, ?), (?, ?), (?, ?) AS `_uql_new` ON DUPLICATE KEY UPDATE `name` = `_uql_new`.`name` ``` MariaDB: ```sql INSERT INTO `User` (`email`, `name`) VALUES (?, ?), (?, ?), (?, ?) ON DUPLICATE KEY UPDATE `name` = VALUE(`name`) RETURNING `id` `id` ``` ### What the upsert result reports `id` (`upsertOne`) and `ids` (`upsertMany`, in payload order) name every row, inserted or updated, on every database. Where the statement cannot report a row’s id (on MySQL, CockroachDB, MSSQL and MongoDB, and for mixed-shape batches everywhere), UQL reads it back by the conflict columns. `created`, on `upsertOne` only, is `true`/`false` on Postgres and MySQL (see [Raw SQL](https://uql-orm.dev/querying/raw-sql.md#run)), and `undefined` elsewhere: CockroachDB, for one, has no equivalent of Postgres’s `xmax` system column. --- # Deep Relations > Populate, filter, and sort across related entities with $populate and relation operators. Source: https://uql-orm.dev/querying/relations Scalars and relations are addressed separately: `$select` and `$exclude` take local scalar columns (strings, numbers, dates, JSONB), and `$populate` takes related entity graphs. ## Querying relations Inside a relation, fields and operators are completed and checked against the related entity. ### Basic Population `$populate` loads a relation and selects its fields: ```ts title="You write" import { pool } from './uql.config.js'; import { User } from './shared/models/index.js'; const users = await pool.findMany(User, { $select: { id: true, name: true }, $populate: { profile: { $select: { picture: true } }, // Load specific fields from a 1-1 relation }, $where: { email: { $iincludes: '@example.com' }, }, }); ``` PostgreSQL: ```sql -- Main query with LEFT JOIN for OneToOne relation SELECT "User"."id", "User"."name", "profile"."id" "profile.id", -- the relation's primary key is always selected "profile"."picture" "profile.picture" -- Prefixed alias for unflattening FROM "User" LEFT JOIN "Profile" "profile" ON "profile"."userId" = "User"."id" WHERE "User"."email" ILIKE $1 -- values: ['%@example.com%'] ``` ### Advanced: Deep Selection & Mandatory Relations Use `$required: true` inside a `$populate` block to enforce an `INNER JOIN` (by default UQL uses `LEFT JOIN`). ```ts title="You write" import { User } from './shared/models/index.js'; const latestUsersWithProfiles = await pool.findOne(User, { $select: { id: true, name: true }, $populate: { profile: { $select: { picture: true, bio: true }, $where: { bio: { $ne: null } }, $required: true, // Enforce INNER JOIN }, }, $sort: { createdAt: 'desc' }, }); ``` PostgreSQL: ```sql -- INNER JOIN enforced by $required: true SELECT "User"."id", "User"."name", "profile"."id" "profile.id", "profile"."picture" "profile.picture", "profile"."bio" "profile.bio" FROM "User" INNER JOIN "Profile" "profile" ON "profile"."userId" = "User"."id" AND "profile"."bio" IS NOT NULL ORDER BY "User"."createdAt" DESC LIMIT 1 ``` ### Filtering on Related Collections A populated collection (one-to-many or many-to-many) takes its own filter, sort and page: ```ts title="You write" import { User } from './shared/models/index.js'; const authorsWithPopularPosts = await pool.findMany(User, { $select: { id: true, name: true }, $populate: { posts: { $select: { title: true, createdAt: true }, $where: { title: { $iincludes: 'typescript' } }, $sort: { createdAt: 'desc' }, $limit: 5, }, }, $where: { name: { $istartsWith: 'a' }, }, // Bound the page too: every parent row this returns reads up to 5 posts of its own. $limit: 20, }); ``` PostgreSQL: ```sql -- One statement: each author's posts are a correlated subquery aggregated as JSON. SELECT "User"."id", "User"."name", (SELECT COALESCE(JSON_AGG("_uql_row" ORDER BY "posts"."_uql_sort_createdAt" DESC), '[]'::json) FROM (SELECT "posts"."title", "posts"."createdAt", "posts"."createdAt" "_uql_sort_createdAt" FROM "Post" "posts" WHERE "posts"."title" ILIKE $1 AND "posts"."authorId" = "User"."id" ORDER BY "_uql_sort_createdAt" DESC LIMIT 5) "posts" CROSS JOIN LATERAL (SELECT "posts"."title", "posts"."createdAt") "_uql_row") "posts" FROM "User" WHERE "User"."name" ILIKE $2 LIMIT 20 -- values: ['%typescript%', 'a%'] ``` MySQL: ```sql -- MySQL orders the posts in GROUP_CONCAT; the hint lifts its 1 KB cap for this statement. SELECT /*+ SET_VAR(group_concat_max_len=18446744073709551615) */ `User`.`id`, `User`.`name`, (SELECT COALESCE(CONCAT('[', GROUP_CONCAT(JSON_OBJECT('title', `posts`.`title`, 'createdAt', `posts`.`createdAt`) ORDER BY `posts`.`_uql_sort_createdAt` DESC SEPARATOR ','), ']'), '[]') FROM (SELECT `posts`.`title`, `posts`.`createdAt`, `posts`.`createdAt` `_uql_sort_createdAt` FROM `Post` `posts` WHERE LOWER(`posts`.`title`) LIKE ? AND `posts`.`authorId` = `User`.`id` ORDER BY `_uql_sort_createdAt` DESC LIMIT 5) `posts`) `posts` FROM `User` WHERE LOWER(`User`.`name`) LIKE ? LIMIT 20 -- values: ['%typescript%', 'a%'] ``` `$limit`, `$skip` and `$sort` are **per parent**: `$limit: 5` gives every author their own 5 newest posts, still in one statement. A parent with no matches gets `[]`. So `$limit: 1` reads each parent’s latest row, which a to-one relation has no order to pick: `$populate: { posts: { $sort: { createdAt: 'desc' }, $limit: 1 } }`. **Bound the parent page too**: the statement reads up to `parents x limit` related rows. `$sort`, `$limit`, `$skip` and `$distinct` describe a collection, so a to-one `$populate` rejects them: it is joined, one row per parent, with nothing to order, page or de-duplicate. `$select`, `$exclude`, `$where` and `$required` apply to either cardinality. ### Sorting by Related Fields `$sort` can name a field on a to-one relation whether or not you populate it. UQL adds the join the ordering needs; `$populate` decides only whether that relation’s columns come back with the rows: ```ts title="You write" import { Item } from './shared/models/index.js'; const items = await pool.findMany(Item, { $select: { id: true, name: true }, $populate: { tax: { $select: { name: true } }, }, $sort: { tax: { name: 1 }, measureUnit: { name: 1 }, createdAt: 'desc', }, }); ``` PostgreSQL: ```sql SELECT "Item"."id", "Item"."name", "tax"."id" "tax.id", "tax"."name" "tax.name" FROM "Item" LEFT JOIN "Tax" "tax" ON "tax"."id" = "Item"."taxId" AND "tax"."deletedAt" IS NULL LEFT JOIN "MeasureUnit" "measureUnit" ON "measureUnit"."id" = "Item"."measureUnitId" AND "measureUnit"."deletedAt" IS NULL ORDER BY "tax"."name", "measureUnit"."name", "Item"."createdAt" DESC ``` `measureUnit` is sorted by but not populated, so its join adds no columns and the rows come back the shape they would without it. It is the same join `$populate` would have made, [filters](https://uql-orm.dev/querying/filters.md) and soft-deletes included, so an ordering can never read a row the query itself cannot. Nested paths join each level: `$sort: { tax: { category: { name: 1 } } }`. Sorting by a relation is rejected where it cannot mean anything, or where nothing can join it: - **a to-many’s fields**, at compile time and at runtime: a parent has many of those rows, so there is nothing single to order it by. Order them inside `$populate`, which sorts the query they are loaded with. A to-many ranks its parent by its size, `{ posts: { $count: -1 } }` ([counting](https://uql-orm.dev/querying/counting.md#ordering-by-a-relations-size)), or by its row nearest a vector ([semantic search](https://uql-orm.dev/querying/semantic-search.md#ranking-by-a-related-row)). - **`updateMany`, `deleteMany`, `$group` aggregates**: none of those statements join. - **`$distinct`**, unless the relation is populated too: `SELECT DISTINCT` orders only by columns it selected. - **MongoDB**, unless every level of the path is populated, since a `$lookup` is what puts its fields on the document. ### Relation Filtering (EXISTS Subqueries) Filter parent entities based on conditions on their **ManyToMany** or **OneToMany** relations. UQL compiles the condition to an `EXISTS` subquery, so it never joins in and duplicates parent rows. The related entity’s own [filters](https://uql-orm.dev/querying/filters.md) apply inside the subquery (the junction’s too, for ManyToMany), so a parent never matches through a row the query could not read, such as a trashed child or one outside a `security: true` filter’s scope. On MongoDB the same conditions compile to correlated `$lookup` stages. #### ManyToMany ```ts title="You write" import { Item } from './shared/models/index.js'; // Find all items that have a tag named 'typescript' const items = await pool.findMany(Item, { $where: { tags: { name: 'typescript' } }, }); ``` PostgreSQL: ```sql SELECT * FROM "Item" WHERE EXISTS ( SELECT 1 FROM "ItemTag" WHERE "ItemTag"."itemId" = "Item"."id" AND "ItemTag"."tagId" IN (SELECT "tags"."id" FROM "Tag" "tags" WHERE "tags"."name" = $1) ) ``` #### OneToMany ```ts title="You write" // Find users who have authored posts with 'typescript' in the title const users = await pool.findMany(User, { $where: { posts: { title: { $iincludes: 'typescript' } } }, }); ``` PostgreSQL: ```sql SELECT * FROM "User" WHERE EXISTS ( SELECT 1 FROM "Post" "posts" WHERE "posts"."authorId" = "User"."id" AND "posts"."title" ILIKE $1 ) -- values: ['%typescript%'] ``` Relation filters sit alongside field comparisons and logical operators in the same `$where`: ```ts const items = await pool.findMany(Item, { $where: { name: { $istartsWith: 'guide' }, tags: { name: 'important' }, }, }); ``` ### Relation Count Filtering (`$size` Subqueries) To *return* or *rank by* a relation’s size rather than filter on it, see [counting relations](https://uql-orm.dev/querying/counting.md#counting-relations). Filter parent entities by the **number** of related records using `$size` on a relation key, type-checked against your entity’s relations and compiled to a `COUNT(*)` subquery. Accepts a number for exact match or any [comparison operator](https://uql-orm.dev/querying/comparison-operators.md) (`$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$between`). The count is scoped by the related entity’s [filters](https://uql-orm.dev/querying/filters.md), exactly like the `EXISTS` form above, so it never counts rows the same query could not read. #### OneToMany ```ts title="You write" // Find categories with at least 2 measure units const categories = await pool.findMany(MeasureUnitCategory, { $where: { measureUnits: { $size: { $gte: 2 } } }, }); ``` PostgreSQL: ```sql SELECT * FROM "MeasureUnitCategory" WHERE (SELECT COUNT(*) FROM "MeasureUnit" "measureUnits" WHERE "measureUnits"."categoryId" = "MeasureUnitCategory"."id" AND "measureUnits"."deletedAt" IS NULL) >= $1 ``` #### ManyToMany ```ts title="You write" // Find items with more than 5 tags const items = await pool.findMany(Item, { $where: { tags: { $size: { $gt: 5 } } }, }); ``` PostgreSQL: ```sql -- Tag has no filters of its own, so the junction count stands alone; when it does have -- them, the counted junction rows narrow to the target ids that satisfy them. SELECT * FROM "Item" WHERE (SELECT COUNT(*) FROM "ItemTag" WHERE "ItemTag"."itemId" = "Item"."id") > $1 ``` #### Multiple Comparison Operators ```ts title="You write" // Find items with between 2 and 10 tags const items = await pool.findMany(Item, { $where: { tags: { $size: { $between: [2, 10] } } }, }); ``` An exact number works here too: `$size: 3` is the same as `$size: { $eq: 3 }`. --- ## Next Steps - [Sub-Queries](https://uql-orm.dev/querying/sub-queries.md): Correlated sub-queries when the built-in ones are not enough. - [Relation Mapping](https://uql-orm.dev/entities/relations.md): How the relations you query here are declared. - [Soft Delete](https://uql-orm.dev/entities/soft-delete.md): The filter that joins add to populated relations. - [Streaming](https://uql-orm.dev/querying/streaming.md): The same relations, row by row. # Counting > Count matching rows, test existence, page with a total, and count or rank by a relation's size, on every SQL dialect and MongoDB. Source: https://uql-orm.dev/querying/counting Five ways to ask “how many”, each with a different cost: | You need | Use | Costs | | - | - | - | | How many match | `count(Entity, query?)` | one statement | | Whether *any* match | `exists(Entity, query?)` | one statement, stops at the first row | | A page **and** its total | `findManyAndCount(Entity, query)` | usually one statement on SQL, two on MongoDB | | How many rows a relation holds | `$count` on a read | inside the read’s own statement | | A rough size of a huge table | `estimatedCount(Entity)` | no scan at all | ## count ```ts await pool.count(User); // every row await pool.count(User, { $where: { active: true } }); // matching rows ``` `$skip` and `$limit` count *that page* rather than every match, which is how you cap the work on a table too big to scan: ```ts await pool.count(User, { $limit: 1000 }); // "1,000+ matches" without counting the rest await pool.count(User, { $skip: 20 }); // how many remain after the first 20 ``` A plain count is `SELECT COUNT(*)`. A paged one counts the page as a derived table, `SELECT COUNT(*) FROM (SELECT id ... LIMIT 1000)`, so no row leaves the database. `count` takes no `$sort`: ordering picks *which* rows a page holds, never how many. ## exists ```ts if (await pool.exists(User, { $where: { email } })) { throw new Error('email already registered'); } ``` A count capped at one row, so the engine stops at the first match instead of scanning the rest. Prefer it over `count(...) > 0`, which counts every match to learn something the first row settles. ## findManyAndCount The page plus how many matched beyond it, which is what a paginated list needs: ```ts const [users, total] = await pool.findManyAndCount(User, { $where: { active: true }, $sort: { createdAt: -1 }, $skip: 40, $limit: 20, }); // users.length === 20, total === 1247 ``` On SQL this is normally **one statement**: the page carries its own unpaged total in a column of its own, so the rows and the total come from the same snapshot and cannot disagree. A second statement is needed only for an empty page (a `$skip` past the end has no row to carry the total on), a `$distinct` read, a `$lock` on PostgreSQL or CockroachDB, and MongoDB, which always uses two. `$distinct` counts the rows you get back, not the rows before deduplication: ```ts // three users, two distinct names const [names, total] = await pool.findManyAndCount(User, { $select: { name: true }, $distinct: true, }); // names.length === 2, total === 2 ``` ## Counting relations `$count` answers how many rows a relation holds, without loading any of them. Results arrive under `_count`: ```ts const users = await pool.findMany(User, { $select: { name: true }, $count: { posts: true }, }); // [{ name: 'Ada', _count: { posts: 42 } }] ``` Name as many relations as you like; each becomes a key of `_count`. Narrow what counts with a filter of its own: ```ts const users = await pool.findMany(User, { $count: { posts: { $where: { published: true } } }, }); // [{ ...user, _count: { posts: 12 } }] // published ones only ``` Counting and populating the same relation are independent: ask for both and get the page of rows *and* the total: ```ts const [user] = await pool.findMany(User, { $select: { name: true }, $populate: { posts: { $sort: { createdAt: -1 }, $limit: 5 } }, $count: { posts: true }, }); user.posts.length; // 5, the newest user._count.posts; // 128, how many there are ``` Each tally is a correlated count **inside the read’s own statement**: no related row is loaded, and it works with `$distinct` and a raw `$select`. Only to-many relations can be counted, since a to-one resolves to at most one row. A tally you always ask for can be a field instead: a [relation aggregate](https://uql-orm.dev/entities/computed-fields.md#relation-aggregates) reads the same subquery under a name of your own, which `$where` and `$sort` can also name. `sum`, `min`, `max` and `avg` are only available that way. ## Ordering by a relation’s size `$sort` ranks parents by a tally, which is how you get a top-N: ```ts const busiest = await pool.findMany(User, { $sort: { posts: { $count: -1 } }, $limit: 10, }); ``` Each parent’s tally is computed as a correlated count, so a top-10 never loads the posts it ranked by. ## estimatedCount ```ts await pool.estimatedCount(User); // 2_400_000, instantly ``` The row count the engine already keeps: Postgres’ `pg_class.reltuples`, CockroachDB’s table statistics, MySQL and MariaDB’s `information_schema`, MSSQL’s `sys.partitions`, MongoDB’s `estimatedDocumentCount`. Nothing is scanned, so it answers in constant time on a table of any size. Use it only where an approximation is fine: - **Approximate**, and as stale as the last `ANALYZE`. - **Whole-table.** It takes no filter, so soft-deleted rows and every [entity filter](https://uql-orm.dev/querying/filters.md) are inside the number. - **SQLite keeps no such statistic** and throws. - **Server-side only**, not exposed to the browser client. ## Filtering by a relation’s size To *filter* parents by a relation’s size, use [`$size`](https://uql-orm.dev/querying/relations.md) in `$where`: ```ts await pool.findMany(User, { $where: { posts: { $size: { $gte: 5 } } } }); ``` `$size` is a comparison, so it lives with the other `$where` operators; `$count` is a value you project or rank by. # Sorting > Order rows with $sort, and say where nulls land so the answer is the same on every engine. Source: https://uql-orm.dev/querying/sorting `$sort` orders by any field of the entity, by a JSON path inside one, and by a joined relation’s field. A direction is `'asc'` / `1` or `'desc'` / `-1`, and keys order by in the order you write them: ```ts const posts = await pool.findMany(Post, { $select: { title: true, publishedAt: true }, $sort: { publishedAt: 'desc', title: 'asc' }, }); ``` ```sql title="PostgreSQL" ORDER BY "publishedAt" DESC, "title" ``` ## Where nulls land A column is nullable in UQL unless it says `nullable: false`, and each engine has its own idea of where those nulls belong: | Engine | Unqualified `asc` puts nulls | | - | - | | PostgreSQL, CockroachDB, Neon, PGlite, Bun SQL | last | | SQLite, libSQL, Turso, D1, MySQL, MariaDB, SQL Server | first | | MongoDB | first (a missing field counts as null) | So the same query answers in a different order depending on where it runs. Say which one you want, and it reads the same everywhere: ```ts const posts = await pool.findMany(Post, { $sort: { publishedAt: 'descNullsLast' }, }); ``` The four placements are `ascNullsFirst`, `ascNullsLast`, `descNullsFirst` and `descNullsLast`. They work anywhere a direction does: a field, a JSON path, a joined relation’s field, a populated relation’s own `$sort`, and an aggregate’s. Each engine gets there its own way, and the result is identical: ```sql title="PostgreSQL, CockroachDB, SQLite, libSQL, Turso, D1" ORDER BY "publishedAt" DESC NULLS LAST ``` ```sql title="MySQL, MariaDB" ORDER BY `publishedAt` IS NULL, `publishedAt` DESC ``` ```sql title="SQL Server" ORDER BY CASE WHEN [publishedAt] IS NULL THEN 1 ELSE 0 END, [publishedAt] DESC ``` MongoDB has no placement at all, so the pipeline flags each row and orders by the flag first, then takes the flag back off before you see the document. > **Ask for a placement only when you need one** > > On the engines with no clause, a placement is an extra `ORDER BY` term, and no index serves an expression: a sort that was an index scan becomes a sort of the matched rows. That is why an unqualified `asc` is left exactly as the engine writes it, and why the placement is opt-in rather than normalized for you. ## Next steps - [Relations](https://uql-orm.dev/querying/relations.md): sorting by a joined field, and a populated collection’s own `$sort`. - [Counting](https://uql-orm.dev/querying/counting.md): ordering parents by how many rows a relation holds. - [Full-text search](https://uql-orm.dev/querying/full-text.md) and [semantic search](https://uql-orm.dev/querying/semantic-search.md): ranking by relevance or vector distance. # Comparison Operators > Filter by equality, ranges, string matching, and lists with UQL comparison operators. Source: https://uql-orm.dev/querying/comparison-operators Each operator is typed by the field it applies to, and only the operators that fit a field’s type are accepted: - **Every field**: `$eq`, `$ne`, `$not`, `$in`, `$nin`, `$isNull`, `$isNotNull` (a bare array is an implicit `$in` on scalar fields). - **Comparable fields** (`string`, `number`, `bigint`, `Date`): `$lt`, `$lte`, `$gt`, `$gte`, `$between`. - **String fields**: `$like`, `$ilike`, `$regex`, `$startsWith`, `$istartsWith`, `$endsWith`, `$iendsWith`, `$includes`, `$iincludes`. - **Array fields**: `$all`, `$size`, `$elemMatch`. An inapplicable combination, such as `{ age: { $like: '3%' } }`, `{ active: { $gt: false } }` or `{ name: { $size: 3 } }`, is a compile error. | Name | Description | | - | - | | `$eq` | Equal to. | | `$ne` | Not equal to (null-safe: rows where the column is `NULL` still match when the value is not null). | | `$lt` | Less than. | | `$lte` | Less than or equal to. | | `$gt` | Greater than. | | `$gte` | Greater than or equal to. | | `$like` | SQL `LIKE` pattern match (case sensitive). E.g. `{ name: { $like: 'John%' } }`. | | `$ilike` | SQL `ILIKE` pattern match (case insensitive). E.g. `{ name: { $ilike: 'john%' } }`. | | `$regex` | Regular expression match. E.g. `{ name: { $regex: '^test' } }`. On MSSQL it needs a 2025 server at compatibility level 170. | | `$startsWith` | Starts with (case-sensitive). | | `$istartsWith` | Starts with (case-insensitive). | | `$endsWith` | Ends with (case-sensitive). | | `$iendsWith` | Ends with (case-insensitive). | | `$includes` | Contains substring (case-sensitive). | | `$iincludes` | Contains substring (case-insensitive). | | `$in` | Value matches any in a given array. | | `$nin` | Value does not match any in a given array. | | `$between` | Value is between two bounds (inclusive). E.g. `{ age: { $between: [18, 65] } }`. | | `$isNull` | Field is null. E.g. `{ deletedAt: { $isNull: true } }`. | | `$isNotNull` | Field is not null. E.g. `{ email: { $isNotNull: true } }`. | | `$all` | Array contains all specified values: a scalar compared by JSON type, so `'5'` is not `5`, and an object or an array matched by what it holds. E.g. `{ tags: { $all: ['ts', 'orm'] } }`. | | `$size` | Array has the specified length. Accepts a number for exact match (`{ tags: { $size: 3 } }`) or comparison operators (`{ tags: { $size: { $gte: 2 } } }`). Also filters by a [relation’s size](https://uql-orm.dev/querying/relations.md#relation-count-filtering-size-subqueries); to *return* or rank by that size instead, see [counting](https://uql-orm.dev/querying/counting.md). | | `$elemMatch` | Array contains an element matching the condition. Object elements take a value or an operator map per key (`{ addresses: { $elemMatch: { city: 'NYC', zip: { $startsWith: '10' } } } }`), a value matched by what it holds as in `$all`; scalar elements take an operator map (`{ tags: { $elemMatch: { $startsWith: 'ad' } } }`), where one `$eq` or `$in` compares by JSON type as `$all` does. | | `$text` | Full-text search. See [Full-Text Search](https://uql-orm.dev/querying/full-text.md) for per-dialect SQL and index requirements. | ## Practical Example ```ts title="You write" import { pool } from './uql.config.js'; import { User } from './shared/models/index.js'; const users = await pool.findMany(User, { $select: { id: true, name: true }, $where: { name: { $istartsWith: 'Some', $ne: 'Something' }, age: { $gte: 18, $lte: 65 }, }, $sort: { name: 'asc' }, $limit: 50, }); ``` ## Context-Aware SQL Generation The case-insensitive operators reach the same rows on every engine, by whichever route that engine has: PostgreSQL and CockroachDB have `ILIKE`; SQLite’s `LIKE` already ignores case on both sides; the MySQL family has neither, so both sides are lowered explicitly, which is why you see `LOWER(...)` there and nowhere else. `$ne` diverges further: `IS DISTINCT FROM`, `NOT (a <=> b)` and `IS NOT` are three spellings of the same null-safe inequality. PostgreSQL: ```sql SELECT "id", "name" FROM "User" WHERE ("name" ILIKE $1 AND "name" IS DISTINCT FROM $2) AND ("age" >= $3 AND "age" <= $4) ORDER BY "name" LIMIT 50 -- values: ['Some%', 'Something', 18, 65] ``` MySQL / MariaDB: ```sql SELECT `id`, `name` FROM `User` WHERE (LOWER(`name`) LIKE ? AND NOT (`name` <=> ?)) AND (`age` >= ? AND `age` <= ?) ORDER BY `name` LIMIT 50 -- values: ['some%', 'Something', 18, 65] ``` SQLite: ```sql SELECT `id`, `name` FROM `User` WHERE (`name` LIKE ? AND `name` IS NOT ?) AND (`age` >= ? AND `age` <= ?) ORDER BY `name` LIMIT 50 -- values: ['Some%', 'Something', 18, 65] ``` > **What case-insensitive costs, per engine** > > On the MySQL family only an [expression index](https://uql-orm.dev/entities/indexes.md) over `LOWER(column)` can serve these. That costs an index for `$istartsWith` alone: the other patterns lead with `%`, which no plain index could serve anyway. Their default collation is case-insensitive, so `$startsWith` matches either case there and keeps its index, at the price of matching case-sensitively on PostgreSQL. On SQLite the folding is ASCII-only whichever way you write it, since the engine has no case mapping for accented characters without ICU. ## `$between`: Range Queries ```ts title="You write" const users = await pool.findMany(User, { $where: { age: { $between: [18, 65] } }, }); ``` PostgreSQL: ```sql SELECT * FROM "User" WHERE "age" BETWEEN $1 AND $2 ``` ## JSONB Dot-Notation Operators The operators above also work on nested JSON paths with **dot-notation** (e.g. `'settings.isArchived': { $ne: true }`) on PostgreSQL, MySQL, MariaDB and SQLite. [JSON / JSONB](https://uql-orm.dev/querying/json.md) covers filtering, the `$set`/`$unset`/`$push`/`$pull` update operators, and sorting by JSON paths. --- ## Next Steps - [Logical Operators](https://uql-orm.dev/querying/logical-operators.md): Compose conditions with `$and`, `$or`, `$not`, `$nor`. - [Deep Relations](https://uql-orm.dev/querying/relations.md): Filter and count across related entities. - [Counting](https://uql-orm.dev/querying/counting.md): Count matches, test existence, and tally relations. - [JSON / JSONB](https://uql-orm.dev/querying/json.md): The same operators on nested JSON paths. - [Querier API](https://uql-orm.dev/querying/querier.md): The full query API these operators live in. # Logical Operators > Combine conditions with $and, $or, $not, and $nor in UQL queries. Source: https://uql-orm.dev/querying/logical-operators Logical operators combine conditions built with [comparison operators](https://uql-orm.dev/querying/comparison-operators.md) into one query. The syntax is MongoDB-inspired and stays valid JSON throughout. | Name | Description | | - | - | | `$and` | joins query clauses with a logical `AND` (default). | | `$or` | joins query clauses with a logical `OR`, returns records that match any clause. | | `$not` | negates the given clause. | | `$nor` | joins query clauses with a logical `OR` and then negates the result. | ## Implicit vs Explicit `$and` The `$and` operator is implicit when you specify multiple fields in the [`$where`](https://uql-orm.dev/querying/filters.md) object. ```ts title="You write" import { pool } from './uql.config.js'; import { User } from './shared/models/index.js'; // Implicit AND const users = await pool.findMany(User, { $where: { name: 'roger', status: 'active' }, }); ``` PostgreSQL: ```sql SELECT * FROM "User" WHERE "name" = $1 AND "status" = $2 ``` The same query with an explicit `$and`: ```ts title="You write" const users = await pool.findMany(User, { $where: { $and: [{ name: 'roger' }, { status: 'active' }], }, }); ``` PostgreSQL: ```sql -- Same result as implicit AND SELECT * FROM "User" WHERE "name" = $1 AND "status" = $2 ``` ## Complex Logical Nesting Logical operators nest: ```ts title="You write" const users = await pool.findMany(User, { $where: { $or: [ { name: { $istartsWith: 'A' } }, { $and: [ { status: 'pending' }, { createdAt: { $lt: new Date('2025-01-01') } }, ], }, ], }, }); ``` PostgreSQL: ```sql SELECT * FROM "User" WHERE "name" ILIKE $1 OR ("status" = $2 AND "createdAt" < $3) ``` ## `$not`: Negate a Condition `$not` wraps conditions with `NOT`. It can be used at the **field level** or at the **top level** as an array of clauses. ```ts title="Field-level $not" const users = await pool.findMany(User, { $where: { status: 'active', name: { $not: { $startsWith: 'test' } }, }, }); ``` PostgreSQL: ```sql SELECT * FROM "User" WHERE "status" = $1 AND NOT ("name" LIKE $2) ``` ```ts title="Top-level $not" const users = await pool.findMany(User, { $where: { $not: [{ name: 'admin' }, { status: 'banned' }], }, }); ``` PostgreSQL: ```sql SELECT * FROM "User" WHERE NOT ("name" = $1 AND "status" = $2) ``` ## `$nor`: Negate an `OR` `$nor` negates combined `OR` conditions: records match only if **none** of the clauses are true. ```ts title="You write" const users = await pool.findMany(User, { $where: { $nor: [{ name: 'admin' }, { status: 'banned' }], }, }); ``` PostgreSQL: ```sql SELECT * FROM "User" WHERE NOT ("name" = $1 OR "status" = $2) ``` --- ## Next Steps - [Comparison Operators](https://uql-orm.dev/querying/comparison-operators.md): Field-level operators to combine. - [Filters (`$where`)](https://uql-orm.dev/querying/filters.md): Where logical operators are used. - [Sub-Queries](https://uql-orm.dev/querying/sub-queries.md): Raw conditions inside `$and`/`$or`. - [Counting](https://uql-orm.dev/querying/counting.md): How many rows a composed filter matches. - [Querier API](https://uql-orm.dev/querying/querier.md): The full query API. # Sub-Queries > Write correlated sub-queries and raw SQL fragments with the raw() helper in UQL. Source: https://uql-orm.dev/querying/sub-queries Sub-queries are written with [`raw`](https://uql-orm.dev/querying/raw-sql.md) expressions: SQL fragments that still get UQL’s parameter binding and dialect-aware generation. ## Using `raw` in `$where` The simplest use of a sub-query is adding a raw SQL condition to your [`$where`](https://uql-orm.dev/querying/filters.md) clause. ```ts title="You write" import { pool } from './uql.config.js'; import { raw, refs } from 'uql-orm'; import { Item } from './shared/models/index.js'; const item = refs(Item); const items = await pool.findMany(Item, { $select: { id: true }, $where: { $and: [{ companyId: 1 }, raw`${item.price} * ${item.stock} > ${10000}`], }, }); ``` PostgreSQL: ```sql SELECT "id" FROM "Item" WHERE "companyId" = $1 AND "price" * "stock" > $2 ``` ## Advanced: Context-Aware Sub-Queries (`$exists` / `$nexists`) For `EXISTS` or `IN` sub-queries, pass `raw` a callback: it receives the `QueryContext` and the `dialect`, so the nested statement is prefixed and spelled for your database. For `EXISTS` over an entity relation, use the built-in [relation filters](https://uql-orm.dev/querying/relations.md) instead. ```ts title="You write" import { raw } from 'uql-orm'; import { User, Item } from './shared/models/index.js'; const items = await pool.findMany(Item, { $select: { id: true }, $where: { $nexists: raw(({ ctx, dialect, escapedPrefix }) => { // Use the dialect to generate a nested SELECT statement dialect.find( ctx, User, { $select: { id: true }, // Correlate on the OUTER alias, captured here: a ref would resolve against the // inner statement's own prefix, which is not what a correlated sub-query wants. // `escapedPrefix` already ends with its dot. $where: { companyId: raw(({ ctx }) => ctx.append(`${escapedPrefix}companyId`), ), }, }, { autoPrefix: true }, ); }), }, }); ``` PostgreSQL: ```sql SELECT "id" FROM "Item" WHERE NOT EXISTS (SELECT "User"."id" FROM "User" WHERE "User"."companyId" = "Item"."companyId") ``` --- ## Understanding `raw()` The `raw()` function from `uql-orm` injects SQL fragments into queries. It has two forms: | Form | Syntax | Use Case | | - | - | - | | **Template** | `` raw`${item.price} > ${limit}` `` | Most SQL: values are bound, refs become columns. | | **Callback** | `raw(({ ctx, dialect, escapedPrefix }) => { ... })` | Complex sub-queries that need dialect-aware SQL generation. | An interpolated `QueryRaw` is emitted in place rather than bound, which is how fragments compose. The callback receives: - **`ctx`**: the `QueryContext` for building parameterized SQL: `ctx.append(sql)` emits SQL as written, `ctx.addValue(val)` binds a value and emits its placeholder. - **`dialect`**: the current SQL dialect instance for generating nested queries (e.g., `dialect.find(...)`). - **`escapedPrefix`**: the escaped alias of the parent table, used to reference parent columns in correlated sub-queries. Beyond `$where`, `raw()` also works as a computed `$select` projection (SQL dialects only); see [raw projections in `$select`](https://uql-orm.dev/querying/querier.md#raw-projections-in-select). --- ## Next Steps - [Raw SQL](https://uql-orm.dev/querying/raw-sql.md): The `raw()` helper and raw statement execution. - [Comparison Operators](https://uql-orm.dev/querying/comparison-operators.md): The conditions a sub-query composes with. - [Relations](https://uql-orm.dev/querying/relations.md): Built-in `EXISTS`/`$size` relation subqueries. - [Querier API](https://uql-orm.dev/querying/querier.md): The full query API. # Raw SQL > Execute vanilla SQL queries with type safety using all() and run(). Source: https://uql-orm.dev/querying/raw-sql `all()` and `run()` execute plain SQL, with the result typed through a generic. > **Raw SQL is not scoped by filters or context** > > [Query filters](https://uql-orm.dev/querying/filters.md) (`security` filters and [soft-delete](https://uql-orm.dev/entities/soft-delete.md) included) and the [multi-tenancy](https://uql-orm.dev/multi-tenancy.md) request context apply only to UQL’s query methods, never to `all()` / `run()`. Scope raw queries by hand (note the explicit `WHERE "deletedAt" IS NULL` below), or rely on database-native row-level security for defense in depth. ## The `raw` tag `raw` is a tagged template. Its literal text is emitted as written, and each interpolation depends on what it is: a value is bound, a field read off `refs(Entity)` becomes its column, and another `raw` is spliced in place. ```ts title="Computed update, value bound" import { raw, refs } from 'uql-orm'; const item = refs(Item); await pool.updateMany( Item, { $where: { id: 1 } }, { price: raw`ROUND(${item.price} * ${1.1}, 2)` }, ); raw`${item.price} > ${minimum}`; raw`LOG10(${100})`.as('score'); // .as() names a $select projection ``` A ref renders its column the way the query would: named through the [naming strategy](https://uql-orm.dev/naming-strategy.md) and `@Field({ name })`, escaped, and qualified by the alias in scope. Unlike a column typed into the text, it follows a rename in your editor. The callback form is for dialect-driven SQL and emits whatever it writes, so bind user input with `ctx.addValue()`. --- ## Available Methods | Method | Returns | Use Case | | - | - | - | | `all(sql, values?)` | `Promise` | `SELECT` queries, reports. | | `run(sql, values?)` | `Promise` | Data manipulation (DML). | --- ## `all()` Use `all()` when you expect a result set. It accepts a generic type to ensure the returned array is fully typed. ```ts title="Select with Generics" import { pool } from './uql.config.js'; interface UserCount { status: string; total: number; } const stats = await pool.all(` SELECT status, COUNT(*) as total FROM "User" WHERE "deletedAt" IS NULL GROUP BY status `); // stats: UserCount[] ``` ## `run()` Use `run()` for `INSERT`, `UPDATE`, or `DELETE` statements (DML) where you only care about the operation’s metadata (e.g., affected rows). ```ts title="Update with Parameters" const result = await pool.run( 'UPDATE "User" SET "status" = $1 WHERE "id" = $2', ['active', 123], ); console.log(result.changes); // Number of affected rows ``` ### Response Metadata `run()` returns a `QueryUpdateResult` object containing: - **`changes`**: Number of rows modified, deleted, or inserted. - **`ids`**: Array of inserted IDs (for bulk inserts). - **`firstId`**: The first inserted ID. - **`created`**: Boolean indicating if a row was created (for `upsert`). Only Postgres and MySQL can reliably tell insert from update (Postgres via its `xmax` system column, MySQL via its `affectedRows` convention); CockroachDB (no `xmax` equivalent), MariaDB, and SQLite always return `undefined` here; check `changes`/`firstId` instead if you need to confirm the row exists. --- ## Raw SQL on the Pool SQL pools expose `all()` and `run()` directly, with the same connection-per-call semantics as the [read helpers](https://uql-orm.dev/querying/querier.md#choosing-poolx-vs-querierx), so two `pool.all()` calls in a `Promise.all` run on separate connections in parallel: ```ts title="Two aggregates, two connections, in parallel" import { pool } from './uql.config.js'; const [payments, usages] = await Promise.all([ pool.all<{ sum: number }>( 'SELECT COALESCE(SUM(value), 0) sum FROM "Payment" WHERE "workspaceId" = $1', [id], ), pool.all<{ sum: number }>( 'SELECT COALESCE(SUM(cost), 0) sum FROM "Usage" WHERE "workspaceId" = $1', [id], ), ]); ``` For multiple statements that must share a connection or run atomically, use `pool.withQuerier()` / `pool.transaction()` and the querier’s `all()`/`run()` instead. --- > **Pass values as parameters, never interpolate them** > > UQL binds the second argument as real query parameters (`$1`, `$2` or `?`), which is what keeps dynamic input out of the SQL text. Building the statement by string concatenation instead reintroduces SQL injection, and nothing would catch that for you. `run()` is on every querier; `all()` is SQL-only (the `SqlQuerier` interface), as are `pool.all()` / `pool.run()` on SQL pools (`SqlQuerierPool`). --- ## Next Steps - [Sub-Queries](https://uql-orm.dev/querying/sub-queries.md): Embedding `raw()` fragments inside a typed query. - [Transactions](https://uql-orm.dev/querying/transactions.md): Running raw statements inside a unit of work. - [Logging & Monitoring](https://uql-orm.dev/logging.md): Seeing the SQL and timings your queries produce. - [Querier API](https://uql-orm.dev/querying/querier.md): The typed API to prefer where it fits. # Aggregate Queries > Use GROUP BY, HAVING, COUNT, SUM, AVG, MIN, MAX, and DISTINCT with UQL's aggregate API. Source: https://uql-orm.dev/querying/aggregate Use `aggregate()` for analytics that involve `GROUP BY`, aggregate functions, and post-aggregation filtering via `HAVING`. Works identically across **all SQL dialects** and **MongoDB**. ## Basic Usage ```ts title="You write" import { pool } from './uql.config.js'; import { Order } from './shared/models/index.js'; const results = await pool.aggregate(Order, { $where: { amount: { $gt: 0 } }, // WHERE: filter rows before grouping $group: { status: true }, // GROUP BY column(s) $select: { total: { $sum: { amount: true } }, // SUM("amount") AS "total" count: { $count: '*' }, // COUNT(*) AS "count" }, $having: { count: { $gt: 5 } }, // Post-aggregation filter $sort: { total: -1 }, // ORDER BY total DESC $limit: 10, }); ``` PostgreSQL: ```sql SELECT "status", SUM("amount") "total", COUNT(*) "count" FROM "Order" WHERE "amount" > $1 GROUP BY "status" HAVING COUNT(*) > $2 ORDER BY SUM("amount") DESC LIMIT 10 ``` MySQL / MariaDB / SQLite: ```sql SELECT `status`, SUM(`amount`) `total`, COUNT(*) `count` FROM `Order` WHERE `amount` > ? GROUP BY `status` HAVING COUNT(*) > ? ORDER BY SUM(`amount`) DESC LIMIT 10 ``` MongoDB: ```json [ { "$match": { "amount": { "$gt": 0 } } }, { "$group": { "_id": { "status": "$status" }, "total": { "$sum": "$amount" }, "count": { "$sum": 1 } } }, { "$project": { "_id": 0, "status": "$_id.status", "total": 1, "count": 1 } }, { "$match": { "count": { "$gt": 5 } } }, { "$sort": { "total": -1 } }, { "$limit": 10 } ] ``` > An aggregate’s `$select` holds computed columns only. The output is exactly the `$group` columns plus the `$select` aliases, so a plain column comes back by grouping on it. ## `$group` and `$select` `$group` lists the columns to group by; `$select` defines the computed columns, each under an alias you choose. Both name fields as keys, as `findMany` does: `$group: { status: true }` and `{ $sum: { amount: true } }`. A typo is a compile error, and an editor rename reaches every one of them. An alias may not repeat a `$group` column, since both would come back under that one name. `$sum` and `$avg` accept numeric columns only. An `$avg` is a `number` whatever it read, since the engine floats it; a `$sum`, a `$min` and a `$max` come back as the column’s own type, so a total over a `bigint` column is a `bigint`. Every op except `$count` is typed `| null`: SQL aggregates NULL over an empty group, and an ungrouped aggregate still returns one row, so a `$where` matching nothing hands you a row of nulls. `$count` answers `0` there instead. The `$select` ops, each under an alias of your choosing: - **`{ $count: '*' }`**: `COUNT(*)`, every row - **`{ $count: { field: true } }`**: `COUNT("field")`, non-null values only - **`{ $sum: { field: true } }`**: `SUM("field")` - **`{ $avg: { field: true } }`**: `AVG("field")` - **`{ $min: { field: true } }`**: `MIN("field")` - **`{ $max: { field: true } }`**: `MAX("field")` - **`{ $countDistinct: { field: true } }`**: `COUNT(DISTINCT "field")` Both keys are optional: `$group` alone is a `DISTINCT`-style query, `$select` alone a grand total across all rows: ```ts title="You write" const [{ revenue }] = await pool.aggregate(Order, { $select: { revenue: { $sum: { amount: true } } }, }); ``` PostgreSQL: ```sql SELECT SUM("amount") "revenue" FROM "Order" ``` MySQL / MariaDB / SQLite: ```sql SELECT SUM(`amount`) `revenue` FROM `Order` ``` ### Distinct aggregates `$countDistinct` aggregates over a field’s distinct values, e.g. how many distinct customers ordered per status: ```ts title="You write" const results = await pool.aggregate(Order, { $group: { status: true }, $select: { customers: { $countDistinct: { customerId: true } } }, }); ``` ```sql title="PostgreSQL" SELECT "status", COUNT(DISTINCT "customerId") "customers" FROM "Order" GROUP BY "status" ``` On MongoDB this compiles to `$addToSet` + a `$project` `$size`, so the result is identical across dialects. `$sum`, `$avg`, `$min` and `$max` have no distinct variant: totalling or averaging deduplicated values is rarely what’s meant, and DISTINCT is a no-op for `$min`/`$max`. ### Grouping by a relation’s field A `$group` key may name a to-one relation’s field by the path through it, under an alias of your choosing. Rows stay flat, keyed by that alias, which `$having` and `$sort` name like any other. Posts per author’s company country: ```ts title="You write" import { Post } from './shared/models/index.js'; const byCountry = await pool.aggregate(Post, { $group: { country: { author: { company: { country: true } } } }, $select: { posts: { $count: '*' }, points: { $sum: { points: true } } }, }); // [{ country: 'CO', posts: 12, points: 340 }, ...] ``` PostgreSQL: ```sql SELECT "author.company"."country" "country", COUNT(*) "posts", SUM("Post"."points") "points" FROM "Post" LEFT JOIN "User" "author" ON "author"."id" = "Post"."authorId" LEFT JOIN "Company" "author.company" ON "author.company"."id" = "author"."companyId" GROUP BY "author.company"."country" ``` MySQL / MariaDB / SQLite: ```sql SELECT `author.company`.`country` `country`, COUNT(*) `posts`, SUM(`Post`.`points`) `points` FROM `Post` LEFT JOIN `User` `author` ON `author`.`id` = `Post`.`authorId` LEFT JOIN `Company` `author.company` ON `author.company`.`id` = `author`.`companyId` GROUP BY `author.company`.`country` ``` A path goes through to-one relations only, at any depth: a to-many would multiply the rows it joins, and every total with them. A row whose relation is missing belongs to no group of the path, so the column is typed as its field is; group by the foreign key (`{ authorId: true }`) to count those rows. On MongoDB the path is a `$lookup` and `$unwind` per relation before the `$group`. ### Filtered aggregates `$where` beside an op aggregates only the rows it passes, so one statement pivots rows into columns. Published posts, drafts and published points per author: ```ts title="You write" const byAuthor = await pool.aggregate(Post, { $group: { authorId: true }, $select: { published: { $count: '*', $where: { published: true } }, drafts: { $count: '*', $where: { published: false } }, publishedPoints: { $sum: { points: true }, $where: { published: true } }, }, }); ``` PostgreSQL: ```sql SELECT "authorId", COUNT("published") "published", COUNT("drafts") "drafts", SUM("publishedPoints") "publishedPoints" FROM ( SELECT "authorId", CASE WHEN "published" = $1 THEN 1 END "published", CASE WHEN "published" = $2 THEN 1 END "drafts", CASE WHEN "published" = $3 THEN "points" END "publishedPoints" FROM "Post" ) "_uql_rows" GROUP BY "authorId" ``` MySQL / MariaDB / SQLite: ```sql SELECT `authorId`, COUNT(`published`) `published`, COUNT(`drafts`) `drafts`, SUM(`publishedPoints`) `publishedPoints` FROM ( SELECT `authorId`, CASE WHEN `published` = ? THEN 1 END `published`, CASE WHEN `published` = ? THEN 1 END `drafts`, CASE WHEN `published` = ? THEN `points` END `publishedPoints` FROM `Post` ) `_uql_rows` GROUP BY `authorId` ``` An op’s `$where` takes the entity’s own fields, and the statement’s `$where` still applies to every row first. A total over no passing rows is `null`, a count `0`. MongoDB reads each as a `$cond` inside its accumulator, translating the comparisons (`$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$between`, `$isNull`, `$isNotNull`) and the logical operators, and refusing any other by name. ## `$where` vs `$having` - **[`$where`](https://uql-orm.dev/querying/filters.md)**: Filters rows **before** grouping (`WHERE` clause). - **[`$having`](#having-operators)**: Filters groups **after** aggregation (`HAVING` clause). ```ts title="You write" const results = await pool.aggregate(Order, { $where: { createdAt: { $gte: new Date('2025-01-01') } }, $group: { status: true }, $select: { count: { $count: '*' } }, $having: { count: { $gt: 10 } }, }); ``` PostgreSQL: ```sql SELECT "status", COUNT(*) "count" FROM "Order" WHERE "createdAt" >= $1 GROUP BY "status" HAVING COUNT(*) > $2 ``` MySQL / MariaDB / SQLite: ```sql SELECT `status`, COUNT(*) `count` FROM `Order` WHERE `createdAt` >= ? GROUP BY `status` HAVING COUNT(*) > ? ``` ## `$having` Operators The `$having` map supports the same [comparison operators](https://uql-orm.dev/querying/comparison-operators.md) as `$where`: | Operator | SQL | Example | | - | - | - | | `$eq` | `=` | `{ count: 5 }` or `{ count: { $eq: 5 } }` | | `$ne` | `<>` | `{ count: { $ne: 0 } }` | | `$gt` / `$gte` | `>` / `>=` | `{ total: { $gte: 100 } }` | | `$lt` / `$lte` | `<` / `<=` | `{ avg: { $lt: 50 } }` | | `$between` | `BETWEEN` | `{ count: { $between: [5, 20] } }` | | `$in` / `$nin` | `IN` / `NOT IN` | `{ count: { $in: [1, 5, 10] } }` | | `$isNull` | `IS NULL` | `{ maxVal: { $isNull: true } }` | | `$isNotNull` | `IS NOT NULL` | `{ maxVal: { $isNotNull: true } }` | ## Sorting, Pagination Results sort by any alias and page with `$skip` / `$limit`: ```ts title="You write" import { User } from './shared/models/index.js'; const results = await pool.aggregate(User, { $group: { status: true }, $select: { count: { $count: '*' } }, $sort: { count: -1 }, $skip: 20, $limit: 10, }); ``` ### Over a relation aggregate A [relation aggregate](https://uql-orm.dev/entities/computed-fields.md#relation-aggregates) groups and totals like any other field. With that page’s `Order`, how many orders hold each number of items, and what they add up to: ```ts title="You write" const bySize = await pool.aggregate(Order, { $group: { itemCount: true }, $select: { orders: { $count: '*' }, revenue: { $sum: { total: true } } }, }); ``` The rows computing each field are read first, then grouped, since SQL Server refuses a subquery inside an aggregate or a `GROUP BY`: PostgreSQL: ```sql SELECT "itemCount", COUNT(*) "orders", SUM("total") "revenue" FROM ( SELECT (SELECT COUNT(*) FROM "OrderItem" "items" WHERE "items"."orderId" = "Order"."id") "itemCount", (SELECT COALESCE(SUM("items_2"."amount"), 0) FROM "OrderItem" "items_2" WHERE "items_2"."orderId" = "Order"."id") "total" FROM "Order" ) "_uql_rows" GROUP BY "itemCount" ``` MySQL / MariaDB / SQLite: ```sql SELECT `itemCount`, COUNT(*) `orders`, SUM(`total`) `revenue` FROM ( SELECT (SELECT COUNT(*) FROM `OrderItem` `items` WHERE `items`.`orderId` = `Order`.`id`) `itemCount`, (SELECT COALESCE(SUM(`items_2`.`amount`), 0) FROM `OrderItem` `items_2` WHERE `items_2`.`orderId` = `Order`.`id`) `total` FROM `Order` ) `_uql_rows` GROUP BY `itemCount` ``` An `$avg` over one averages the per-order values, not the items behind them. ## `$distinct` For simple `SELECT DISTINCT` queries (without aggregation), add `$distinct: true` to any find query: ```ts title="You write" const names = await pool.findMany(User, { $select: { name: true }, $distinct: true, }); ``` PostgreSQL: ```sql SELECT DISTINCT "name" FROM "User" ``` MySQL / MariaDB / SQLite: ```sql SELECT DISTINCT `name` FROM `User` ``` `$distinct` is a modifier on [`findMany`](https://uql-orm.dev/querying/querier.md), not part of `aggregate()`. Use `aggregate()` when you need `GROUP BY`, aggregate functions, or `HAVING` filters. --- ## Next Steps - [Querier API](https://uql-orm.dev/querying/querier.md): Full find/select/sort/pagination reference. - [Logical Operators](https://uql-orm.dev/querying/logical-operators.md): Compose the `$where` that runs before grouping. - [Comparison Operators](https://uql-orm.dev/querying/comparison-operators.md): Every operator usable in `$having`. - [Sub-Queries](https://uql-orm.dev/querying/sub-queries.md): Correlated sub-queries and raw SQL fragments. # JSON / JSONB > Work with JSON/JSONB fields with type-safe filtering, atomic updates, and sorting across PostgreSQL, MySQL, MariaDB, and SQLite. Source: https://uql-orm.dev/querying/json Query, update and sort by nested JSON properties with one type-safe API on **PostgreSQL**, **MySQL**, **MariaDB** and **SQLite** (and [MongoDB](#dialect-compatibility)). The PostgreSQL tabs below show what `PgQuerierPool` produces. The other Postgres pools differ only in how they spell a JSON parameter; see [Dialect Compatibility](#dialect-compatibility). ## Entity Setup Wrap JSONB field types with `Json` to enable full type safety: IDE autocompletion for dot-notation paths, `$set` keys, `$unset` keys, and `$push`/`$pull` targets. ```ts import { Entity, Id, Field, type Json } from 'uql-orm'; @Entity() export class Company { @Id({ type: Number }) id?: number; @Field({ type: String }) name?: string | null; @Field({ type: 'jsonb' }) settings?: Json<{ theme?: string; locale?: string; isArchived?: boolean; seats?: number; tags?: string[]; }> | null; } ``` The `Json` marker is what makes the column a field rather than a relation. Without it a plain object type like `{ seats?: number }` is classified as a `RelationKey`, and `$where`, `$select`, `$sort` and `$set` stop accepting it. A column holding a list of documents is declared `Json[]`, also a field rather than a to-many relation. Its dot-paths address the element: ```ts @Entity() export class Order { @Id({ type: Number }) id?: number; @Field({ type: 'jsonb' }) lines?: Json<{ sku: string; qty: number }>[] | null; } await pool.findMany(Order, { $where: { 'lines.sku': 'ACME-1' }, $sort: { 'lines.qty': 'desc' }, }); ``` --- ## Starting Data Every example on this page runs against this single row, so you can follow how its JSON document changes step by step. ```ts title="You write" import { pool } from './uql.config.js'; import { Company } from './shared/models/index.js'; const id = await pool.insertOne(Company, { name: 'Acme', settings: { theme: 'dark', locale: 'en', isArchived: false, seats: 12, tags: ['legacy', 'stale-tag'], }, }); ``` PostgreSQL: ```sql INSERT INTO "Company" ("name", "settings") VALUES ($1, $2::jsonb) RETURNING "id" "id" -- values: ['Acme', '{"theme":"dark","locale":"en","isArchived":false,"seats":12,"tags":["legacy","stale-tag"]}'] ``` MySQL: ```sql INSERT INTO `Company` (`name`, `settings`) VALUES (?, ?) -- values: ['Acme', '{"theme":"dark","locale":"en","isArchived":false,"seats":12,"tags":["legacy","stale-tag"]}'] ``` MariaDB: ```sql INSERT INTO `Company` (`name`, `settings`) VALUES (?, ?) RETURNING `id` `id` -- values: ['Acme', '{"theme":"dark","locale":"en","isArchived":false,"seats":12,"tags":["legacy","stale-tag"]}'] ``` SQLite: ```sql INSERT INTO `Company` (`name`, `settings`) VALUES (?, ?) RETURNING `id` `id` -- values: ['Acme', '{"theme":"dark","locale":"en","isArchived":false,"seats":12,"tags":["legacy","stale-tag"]}'] ``` The document is stringified in the ORM and bound as a single parameter, so an insert writes the whole value at once. Inserts and upserts take plain values only: the operators below belong to update payloads. --- ## Filtering (Dot-Notation) A dot-notation path in `$where` takes every [comparison operator](https://uql-orm.dev/querying/comparison-operators.md) its value type allows. Each path resolves that type from `Json`, so a typo’d path (`'settings.thme'`), a dot-path on a non-JSON field, an operator the type does not allow (`$size` on a string), or a mismatched value are all compile errors. An untyped `Json` field stays permissive: any `field.suffix` path is accepted. ```ts title="You write" const companies = await pool.findMany(Company, { $where: { 'settings.isArchived': { $ne: true }, 'settings.theme': 'dark', }, }); // -> matches the row above: isArchived is false and theme is 'dark' ``` PostgreSQL: ```sql SELECT * FROM "Company" WHERE ("settings"->'isArchived') IS DISTINCT FROM $1::jsonb AND ("settings"->>'theme') = $2 -- values: ['true', 'dark'] ``` MySQL: ```sql SELECT * FROM `Company` WHERE NOT (`settings`->'$.isArchived' <=> CAST(? AS JSON)) AND (`settings`->>'$.theme') = ? -- values: ['true', 'dark'] ``` MariaDB: ```sql SELECT * FROM `Company` WHERE NOT (JSON_EXTRACT(`settings`, '$.isArchived') <=> JSON_EXTRACT(?, '$')) AND JSON_VALUE(`settings`, '$.theme') = ? -- values: ['true', 'dark'] ``` SQLite: ```sql SELECT * FROM `Company` WHERE (`settings`->'isArchived') IS NOT JSON(?) AND JSON_EXTRACT(`settings`, '$.theme') = ? -- values: ['true', 'dark'] ``` JSON-path `$ne` is null-safe on every dialect, so rows whose key is absent are included: PostgreSQL uses `IS DISTINCT FROM`, SQLite `IS NOT`, and MySQL and MariaDB negate the null-safe `<=>`. MariaDB has no equivalent of MySQL’s `->` and `->>` shorthand, so UQL writes `JSON_VALUE()` there for dot-notation filtering and sorting, and `JSON_EXTRACT()` where the comparison is against a JSON value such as a boolean, object or array. --- ## Updating (`$set` / `$unset` / `$push` / `$pull`) Merge or remove keys atomically from an update payload, without rewriting the whole document. Each example starts from the row inserted above, and its trailing comment shows the resulting `settings`. ### `$set`: Assign Keys Assigns top-level keys; keys not named are preserved. ```ts title="You write" await pool.updateOneById(Company, id, { settings: { $set: { theme: 'light' } }, // -> theme: 'light', every other key untouched }); ``` PostgreSQL: ```sql UPDATE "Company" SET "settings" = COALESCE("settings", '{}'::jsonb) || $1::jsonb WHERE "id" = $2 -- values: ['{"theme":"light"}', id] ``` MySQL: ```sql UPDATE `Company` SET `settings` = JSON_SET(COALESCE(`settings`, '{}'), '$.theme', CAST(? AS JSON)) WHERE `id` = ? -- values: ['"light"', id] ``` MariaDB: ```sql UPDATE `Company` SET `settings` = JSON_SET(COALESCE(`settings`, '{}'), '$.theme', JSON_EXTRACT(?, '$')) WHERE `id` = ? -- values: ['"light"', id] ``` SQLite: ```sql UPDATE `Company` SET `settings` = JSON_SET(COALESCE(`settings`, '{}'), '$.theme', JSON(?)) WHERE `id` = ? -- values: ['"light"', id] ``` ### `$unset`: Remove Keys Remove specific keys from a JSON field. ```ts title="You write" await pool.updateOneById(Company, id, { settings: { $unset: ['locale'] }, // -> the locale key is gone }); ``` PostgreSQL: ```sql UPDATE "Company" SET "settings" = ("settings") - $1::text[] WHERE "id" = $2 -- values: [['locale'], id] ``` MySQL: ```sql UPDATE `Company` SET `settings` = JSON_REMOVE(`settings`, '$.locale') WHERE `id` = ? -- values: [id] ``` MariaDB: ```sql UPDATE `Company` SET `settings` = JSON_REMOVE(`settings`, '$.locale') WHERE `id` = ? -- values: [id] ``` SQLite: ```sql UPDATE `Company` SET `settings` = JSON_REMOVE(`settings`, '$.locale') WHERE `id` = ? -- values: [id] ``` ### `$push`: Append to Array Append a value to the end of a JSON array. Only keys whose type is an array are valid `$push` targets (type-checked at compile time). A missing key is created as a single-element array on every dialect. ```ts title="You write" await pool.updateOneById(Company, id, { settings: { $push: { tags: 'new-tag' } }, // -> tags: ['legacy', 'stale-tag', 'new-tag'] }); ``` PostgreSQL: ```sql UPDATE "Company" SET "settings" = JSONB_SET("settings", '{tags}', COALESCE(("settings")->'tags', '[]'::jsonb) || JSONB_BUILD_ARRAY($1::jsonb)) WHERE "id" = $2 -- values: ['"new-tag"', id] ``` MySQL: ```sql UPDATE `Company` SET `settings` = JSON_MERGE_PRESERVE(`settings`, JSON_OBJECT('tags', JSON_ARRAY(CAST(? AS JSON)))) WHERE `id` = ? -- values: ['"new-tag"', id] ``` MariaDB: ```sql UPDATE `Company` SET `settings` = JSON_MERGE_PRESERVE(`settings`, JSON_OBJECT('tags', JSON_ARRAY(JSON_EXTRACT(?, '$')))) WHERE `id` = ? -- values: ['"new-tag"', id] ``` SQLite: ```sql UPDATE `Company` SET `settings` = JSON_SET(`settings`, '$.tags[#]', JSON(?)) WHERE `id` = ? -- values: ['"new-tag"', id] ``` ### `$pull`: Remove From Array Remove **every** element equal to the given value. Like `$push`, only array-typed keys are valid targets and the value is typed as the array’s element. ```ts title="You write" await pool.updateOneById(Company, id, { settings: { $pull: { tags: 'stale-tag' } }, // -> tags: ['legacy'] }); ``` PostgreSQL: ```sql UPDATE "Company" SET "settings" = JSONB_SET("settings", '{tags}', CASE WHEN JSONB_TYPEOF(("settings"->'tags')) = 'array' THEN COALESCE(( SELECT JSONB_AGG(_uql_pull.val ORDER BY _uql_pull.ord) FROM JSONB_ARRAY_ELEMENTS(("settings"->'tags')) WITH ORDINALITY AS _uql_pull(val, ord) WHERE _uql_pull.val <> $1::jsonb), '[]'::jsonb) ELSE COALESCE(("settings"->'tags'), 'null') END, false) WHERE "id" = $2 -- values: ['"stale-tag"', id] ``` MySQL: ```sql UPDATE `Company` SET `settings` = JSON_REPLACE(`settings`, '$.tags', CASE WHEN JSON_TYPE(`settings`->'$.tags') = 'ARRAY' THEN ( SELECT COALESCE(JSON_ARRAYAGG(_uql_pull.v), JSON_ARRAY()) FROM JSON_TABLE(`settings`, '$.tags[*]' COLUMNS (v JSON PATH '$')) AS _uql_pull WHERE _uql_pull.v <> CAST(? AS JSON)) ELSE `settings`->'$.tags' END) WHERE `id` = ? -- values: ['"stale-tag"', id] ``` MariaDB: ```sql UPDATE `Company` SET `settings` = JSON_REPLACE(`settings`, '$.tags', CASE WHEN JSON_TYPE(JSON_EXTRACT(`settings`, '$.tags')) = 'ARRAY' THEN ( SELECT COALESCE(JSON_ARRAYAGG(JSON_COMPACT(_uql_pull.v)), JSON_ARRAY()) FROM JSON_TABLE(`settings`, '$.tags[*]' COLUMNS (v JSON PATH '$')) AS _uql_pull WHERE NOT JSON_EQUALS(_uql_pull.v, JSON_EXTRACT(?, '$'))) ELSE JSON_EXTRACT(`settings`, '$.tags') END) WHERE `id` = ? -- values: ['"stale-tag"', id] ``` SQLite: ```sql UPDATE `Company` SET `settings` = JSON_REPLACE(`settings`, '$.tags', CASE WHEN JSON_TYPE(`settings`, '$.tags') = 'array' THEN ( SELECT JSON_GROUP_ARRAY(JSON(`settings` -> _uql_pull.fullkey)) FROM JSON_EACH(CASE WHEN JSON_TYPE(`settings`, '$.tags') = 'array' THEN `settings` END, '$.tags') _uql_pull WHERE `settings` -> _uql_pull.fullkey <> JSON(?)) ELSE (`settings` -> '$.tags') END) WHERE `id` = ? -- values: ['"stale-tag"', id] ``` A `$pull` on a key that does not exist, on a key holding no array, or on a `NULL` column is a **no-op**: it never creates the key, never changes a value that is no array, and never nulls the document. Removing the last element leaves an empty array, not a missing key. Object elements are the one case where the engines differ. PostgreSQL, MySQL and MariaDB compare them semantically, so key order does not matter; SQLite, SQL Server and MongoDB compare them as written, so an object element matches only when its key order matches what is stored. Scalar elements are exact everywhere. ### Combining Operators All four operators combine in a single atomic update, applied in a fixed order (**`$pull`, `$set`, `$push`, `$unset`**), so every combination gives the same result on every dialect. ```ts title="You write" await pool.updateOneById(Company, id, { settings: { $set: { theme: 'light' }, $push: { tags: 'new-tag' }, $unset: ['locale'], }, // -> { theme: 'light', isArchived: false, seats: 12, tags: ['legacy', 'stale-tag', 'new-tag'] } }); ``` That order is what makes “replace an element” a single atomic statement: the `$pull` filters the stored array and the `$push` appends to that result. ```ts title="Atomically replace a tag" await pool.updateOneById(Company, id, { settings: { $pull: { tags: 'stale-tag' }, $push: { tags: 'fresh-tag' } }, // -> tags: ['legacy', 'fresh-tag'] }); ``` Combining two operators on the *same* key works as well, and follows the same order: a `$set` replaces the array outright, so a `$push` beside it appends to the value you just set. ```ts title="Set then append, on one key" await pool.updateOneById(Company, id, { settings: { $set: { tags: ['kept'] }, $push: { tags: 'appended' } }, // -> tags: ['kept', 'appended'] }); ``` > **$set is shallow** > > `$set` overwrites the top-level keys you name: arrays are replaced entirely, not appended to. To append elements to a JSON array, use `$push`; to remove them, `$pull`. All four operators belong to update payloads (`updateOneById`, `updateMany`, and so on). `upsertOne`, `upsertMany` and `saveOne` take a whole entity and accept plain values only, so passing an operator object there is a compile error. Their keys are checked against the JSON field’s inner type `T`, so the editor completes valid keys and rejects the rest. `$push` and `$pull` narrow further to array-typed keys and expect the array’s element type. A column holding an array at the top level (`Json` or `Json[]`) accepts none of the four, since they all address object keys of one document: assign the whole value instead. An untyped `Json` stays permissive. Extracting a value from a JSON document yields text and loses its type, so UQL compares each scalar in the representation every engine agrees on: numbers numerically, fractions included, so a stored `1.0` still matches `1`; booleans as JSON; strings as text. A number is cast on both sides, which is what lets an index over the path serve the comparison. That shows up in the SQL as a numeric cast or a JSON-valued accessor: MySQL: ```sql CAST((`settings`->>'$.seats') AS DOUBLE) > CAST(? AS DOUBLE) -- numeric operand `settings`->'$.isArchived' = CAST(? AS JSON) -- boolean operand (`settings`->>'$.theme') = ? -- string operand ``` --- ## Sorting (Dot-Notation) `$sort` takes the same paths: ```ts title="You write" const companies = await pool.findMany(Company, { $sort: { 'settings.seats': 'desc' }, }); ``` A number sorts by its value on every engine. PostgreSQL, CockroachDB and MySQL order the JSON value itself; SQLite’s `JSON_EXTRACT` already answers a number as one; MariaDB and SQL Server, which order JSON as text, sort by the number first and then by the text. PostgreSQL: ```sql SELECT * FROM "Company" ORDER BY ("settings"->'seats') DESC ``` MySQL: ```sql SELECT * FROM `Company` ORDER BY `settings`->'$.seats' DESC ``` MariaDB: ```sql SELECT * FROM `Company` ORDER BY CAST(JSON_VALUE(`settings`, '$.seats') AS DOUBLE) DESC, JSON_VALUE(`settings`, '$.seats') DESC ``` SQLite: ```sql SELECT * FROM `Company` ORDER BY JSON_EXTRACT(`settings`, '$.seats') DESC ``` --- ## Supported Dialects | Feature | PostgreSQL | MySQL | MariaDB | SQLite | | - | - | - | - | - | | Dot-notation filtering | `->>'key'` | `->>'key'` | `JSON_VALUE()` | `JSON_EXTRACT()` | | `$set` | `\|\| ::jsonb` | `JSON_SET()` | `JSON_SET()` | `JSON_SET()` | | `$unset` | `- ::text[]` | `JSON_REMOVE()` | `JSON_REMOVE()` | `JSON_REMOVE()` | | `$push` | `JSONB_SET()` + `\|\|` | `JSON_MERGE_PRESERVE()` | `JSON_MERGE_PRESERVE()` | `JSON_SET()` | | `$pull` | `JSONB_AGG()` filter | `JSON_TABLE()` filter | `JSON_TABLE()` + `JSON_EQUALS()` | `JSON_EACH()` filter | | Dot-notation sorting | `->'key'` | `->'key'` | `CAST(JSON_VALUE())`, then text | `JSON_EXTRACT()` | | `$size` | `JSONB_ARRAY_LENGTH()` | `JSON_LENGTH()` | `JSON_LENGTH()` | `JSON_ARRAY_LENGTH()` | | `$all` | `@> ::jsonb` | `JSON_CONTAINS()` | `JSON_CONTAINS()` | `JSON_EACH()` | | `$elemMatch` | `JSONB_ARRAY_ELEMENTS` | `JSON_TABLE()`¹ | `JSON_TABLE()` | `JSON_EACH()` | ¹ One `$eq` or `$in` on an element compiles to `$all`’s containment instead (`JSON_OVERLAPS()` for several on MySQL), which compares by JSON type and which an index serves. An element’s fields are read as any path is. An object in `$all` or `$elemMatch`, or an array in `$all`, matches an element holding what it holds, whatever else the element has: an object’s keys, nested ones too, and an array’s elements. That is `@>` on PostgreSQL, `JSON_CONTAINS()` on MySQL and MariaDB, and the same reading everywhere else. A key given an operator map is compared as a path is, and the keys beside it still match by what they hold. MySQL and MariaDB also look inside an element that is itself an array, so there `$all: [3]` matches `[[3]]`. A path holding no array, but a scalar or an object, matches no `$size` or `$elemMatch`, and no `$all` on the SQL engines; MongoDB’s `$all` matches a scalar equal to its one value. A path compares as its operand does: a number numerically, and only where the path holds a number on PostgreSQL; a string as text; a boolean as JSON. `$not` and `$between` apply to a path as to a column. ## Dialect Compatibility Minimum versions for the SQL on this page: | Dialect | Practical baseline | Version-specific caveats | | - | - | - | | PostgreSQL | 16+ | None; every operator above is available across supported lines. | | MySQL | 8.4+ | `$pull` needs 8.0.4+, for `JSON_TABLE`. | | MariaDB | 12.2+ | `$pull` needs 10.7+, for `JSON_EQUALS`. | | SQLite | 3.45+ | `$pull` needs 3.38+, for the `->` operator. | On PostgreSQL prefer `type: 'jsonb'` to `type: 'json'`. JSONB is stored binary and is indexable, and the array operators (`$size`, `$all`, `$elemMatch`) compile to `JSONB_ARRAY_LENGTH`, `@>` and `JSONB_ARRAY_ELEMENTS`, none of which a `json` column supports. A path these queries filter on can be indexed with [`jsonPath`](https://uql-orm.dev/entities/indexes.md#json-indexes), and an array that `$all` or a scalar `$elemMatch` searches with `jsonArray`, MySQL’s multi-valued index. Both compile to the expression the filter compares, which is what lets the planner use them. Every Postgres pool builds the same `PostgresDialect`; what differs is how its driver binds. Under `PgQuerierPool`, Neon, PGlite and CockroachDB a JSON value binds as `$N::jsonb` and arrays go native for `ANY`/`ALL`. `BunSqlQuerierPool` passes the wire driver’s capabilities instead, which write `( $N::text )::jsonb` where Bun’s client needs it and encode those arrays as literals. MongoDB stores JSON natively and the update operators map onto its own: `$set` becomes dotted-path assignments, and `$unset` and `$push` map one to one. Its own `$pull` fails on a value that is no array, and it rejects two operators targeting one path in a single update document, so a payload with a `$pull`, or naming a path in more than one operator group, is emitted as one aggregation-pipeline update, in the same order, so the result stays atomic and matches the SQL dialects. --- ## Next Steps - [Comparison Operators](https://uql-orm.dev/querying/comparison-operators.md): The operator set dot-notation paths draw from. - [Decorators](https://uql-orm.dev/entities/basic.md): Declaring `json` / `jsonb` fields and other column types. - [Full-Text Search](https://uql-orm.dev/querying/full-text.md): Searching text columns instead of JSON paths. - [Querier API](https://uql-orm.dev/querying/querier.md): The read and write methods these payloads go to. # Full-Text Search > Search and rank text with $text on PostgreSQL, CockroachDB, MySQL, MariaDB, SQLite and MongoDB, with one index declaration and per-column weights. Source: https://uql-orm.dev/querying/full-text `$text` searches natural-language text with each database’s own full-text engine. Declare the fulltext index once, and a query names nothing but the text: ```ts title="You write" import { Entity, Field, Id, Index } from 'uql-orm'; import { pool } from './uql.config.js'; @Index( (listing) => [{ column: listing.title, weight: 3 }, listing.description], { type: 'fulltext', config: 'spanish', }, ) @Entity() export class Listing { @Id({ type: Number }) id?: number; @Field({ type: String }) title?: string | null; @Field({ type: String }) description?: string | null; @Field({ type: String }) status?: string | null; @Field({ type: Number }) createdAt?: number | null; } const found = await pool.findMany(Listing, { $where: { status: 'active', $text: { $value: 'wireless keyboard' } }, $sort: { $text: 'desc', createdAt: 'desc' }, $limit: 20, }); ``` `$text` goes at the top level of `$where`, beside ordinary conditions. It searches the columns of the entity’s fulltext index, in the index’s language, and `$sort: { $text }` ranks by the same search: most relevant first, newest first among equals. A title match outranks the same word in the description, because the title weighs 3. To search other columns, or in another language, name them: `$fields: { name: true }` is keyed like `$select`, so a typo is a compile error and a rename reaches it, and `$config: 'english'` sets the language. On SQL, an entity with no fulltext index, or more than one, has to name `$fields`. ## What each engine runs [Migrations](https://uql-orm.dev/migrations.md) create the index on every engine that has one, from the same declaration. | Engine | Index a migration creates | `$text` runs | | - | - | - | | PostgreSQL / CockroachDB | a `GIN` index over `TO_TSVECTOR(config, cols)` | `... @@ WEBSEARCH_TO_TSQUERY($1)`; CockroachDB has none, so `PLAINTO_TSQUERY` reads the text as plain words | | MySQL / MariaDB | `FULLTEXT (cols)`, over **exactly** the columns searched, in order | `MATCH(cols) AGAINST(?)`; without that index the server answers “Can’t find FULLTEXT index matching the column list” | | MongoDB | a `text` index, which declares its own fields, so `$fields` is accepted and ignored | `{ $text: { $search } }` | | SQLite | none: an [FTS5](https://sqlite.org/fts5.html) virtual table, which you create yourself | `table MATCH ?`, bound as an FTS5 query with each word quoted; ranks by `BM25` | | SQL Server | refused: it needs a [full-text catalogue](https://uql-orm.dev/mssql.md) UQL does not create | refused | PostgreSQL answers `$text` without an index, by scanning; declare one for anything large. It reads free-form input the way a search box does: quoted phrases, `or` and `-negation`, and it never raises a syntax error. ## Ranking by relevance `$sort: { $text: 'desc' }` orders by each engine’s own score: `TS_RANK` on PostgreSQL and CockroachDB, `MATCH ... AGAINST` on MySQL and MariaDB, `BM25` on SQLite, `textScore` on MongoDB. It takes either direction, like any sort key, and other keys break ties after it. It ranks by the `$text` at the root of the same query’s `$where`, and throws without one: a search nested in `$or` or `$not` has no single score to order by. To read the relevance, `$project` it under a name, as a [vector search](https://uql-orm.dev/querying/semantic-search.md) projects its distance. It orders most relevant first unless `$order: 'asc'` says otherwise, and the field is not inferred, so annotate it: ```ts import type { WithProjection } from 'uql-orm'; const items = (await pool.findMany(Item, { $where: { $text: { $value: 'wireless keyboard' } }, $sort: { $text: { $project: 'score' } }, })) as WithProjection[]; // items[0].score is the highest relevance ``` The name has to be a plain one that no field, column or relation of the entity already has, since it is a key of every row. ### Column weights A column without a `weight` weighs 1, and a match counts its column’s weight, as MongoDB’s `textScore` counts it. A weight is a whole number from 1 to 99999, the range MongoDB takes. Weights only rank: `$text` matches the same rows through the same index either way. - **PostgreSQL and CockroachDB** add each heavier column’s own `TS_RANK` to the score. The index and the match are unchanged. - **MySQL and MariaDB** add each heavier column’s own `MATCH`, which reads a `FULLTEXT` index over that column alone, so migrations create one for each column heavier than the lightest. - **MongoDB** weighs the fields in the text index itself, and `drift:check` compares the weights. > **MySQL and MariaDB rebuild the table** > > InnoDB fills a fulltext index added to a table that already has one, and rows, only once the table is optimized: until then MariaDB scores it 0 and MySQL can fail the search. So a migration adding a fulltext index to an existing table follows it with `OPTIMIZE TABLE`, which rebuilds the table, as adding its first fulltext index does. ## Language A fulltext index’s `config` and a search’s `$config` name the language that drives stemming and stop words: `'english'`, `'spanish'`, or `'simple'` for none, which is what a `config`-less index builds with on every engine. - **PostgreSQL and CockroachDB** use it as the text-search configuration, for the index and the query alike. It is written as a literal, as the index is built over one; the search text is bound. - **MongoDB** uses the same names as its language, for the index (`default_language`) and the search (`$language`), with `'simple'` its `'none'`. `drift:check` reports an index built in another language. - **MySQL and SQLite** parse by their index alone. ```ts const items = await pool.findMany(Item, { $where: { $text: { $fields: { name: true }, $value: 'running shoes', $config: 'english', }, }, }); ``` > **Combining with semantic search** > > Full-text search finds exact term matches; [vector search](https://uql-orm.dev/querying/semantic-search.md) finds semantically similar text. Hybrid search runs both and fuses the rankings: MongoDB does this natively with `$rankFusion`, and on PostgreSQL it is a reciprocal-rank-fusion query over a `tsvector` match plus a pgvector distance sort. --- ## Next Steps - [Indexes](https://uql-orm.dev/entities/indexes.md): Declaring `fulltext` indexes and their per-column options. - [Semantic Search](https://uql-orm.dev/querying/semantic-search.md): Vector similarity when keywords are not enough. - [Comparison Operators](https://uql-orm.dev/querying/comparison-operators.md): Combining `$text` with ordinary conditions. - [Querier API](https://uql-orm.dev/querying/querier.md): The full query API. # Semantic Search > Vector similarity search with $vector, $near, $candidates, $distance, and $project across PostgreSQL, CockroachDB, MariaDB, SQLite, libSQL, Turso, MSSQL, and MongoDB Atlas. Source: https://uql-orm.dev/querying/semantic-search UQL supports vector similarity search natively, on **PostgreSQL** (pgvector), **CockroachDB**, **MariaDB**, **SQLite** (sqlite-vec), **libSQL** and **Turso** (built in, no extension), **MSSQL** (SQL Server 2025, exact), and **MongoDB Atlas** (`$vectorSearch`). The same `$vector` query works on all of them. This page is the operator reference; for an end-to-end walkthrough (ingestion, querying, RAG thresholds), see [AI & RAG](https://uql-orm.dev/ai-semantic-search.md). ## Entity Setup Define a vector field with `type: 'vector'` and `dimensions`. Optionally, add a vector index for efficient approximate nearest-neighbor (ANN) search. ```ts title="You write" import { Entity, Id, Field, Index } from 'uql-orm'; @Entity() @Index((article) => [article.embedding], { type: 'hnsw', distance: 'cosine', m: 16, efConstruction: 64, }) export class Article { @Id({ type: Number }) id?: number; @Field({ type: String }) title?: string | null; @Field({ type: String }) category?: string | null; @Field({ type: 'vector', dimensions: 1536 }) embedding?: number[] | null; } ``` For Postgres, UQL emits `CREATE EXTENSION IF NOT EXISTS vector` when your schema includes vector columns, and index migrations pick up the HNSW and IVFFlat parameters (`m`, `efConstruction`, `lists`) from the `@Index` decorator, so index changes ship with your normal migrations. CockroachDB’s `VECTOR` type and vector index are native, needing no extension, and use the same `<=>`/`<->`/`<#>` operators as Postgres. See [Vector Indexes](#vector-indexes) below for its index syntax. ### MariaDB Vector search is built in from 11.7, with `VECTOR(n)` columns holding a packed float32 blob rather than text. UQL binds and reads those bytes, so a vector field reads back as the `number[]` it was written, every float32 digit kept. Two MariaDB rules to know: `dimensions` is required on the field, and a column carrying a vector index is emitted `NOT NULL`, because MariaDB rejects the index otherwise. ### SQLite, libSQL and Turso libSQL and Turso have vector functions built in, so their entities need nothing extra. Plain SQLite has none, and gets them from [sqlite-vec](https://github.com/asg017/sqlite-vec): pass its path to the pool, which loads it on the connection: ```ts title="Loading sqlite-vec" import { getLoadablePath } from 'sqlite-vec'; import { Sqlite3QuerierPool } from 'uql-orm/sqlite'; const pool = new Sqlite3QuerierPool('app.db', { extensions: [getLoadablePath()], }); ``` Vectors are stored as float32 blobs (`F32_BLOB(n)`) on all three; a `TEXT` column from an older version keeps working. D1, which has no vector search, stores them as text. ### SQL Server Vector search needs SQL Server 2025, where `VECTOR(n)` and `VECTOR_DISTANCE` exist, and `dimensions` is required on the field. The query vector is cast to `VECTOR(n)`, since the function refuses the `NVARCHAR` it binds as. Search is exact: the DiskANN index is still a preview feature, so every distance is computed. > **MySQL and Cloudflare D1 have no vector search** > > MySQL has a `VECTOR` type but no distance function outside HeatWave and no vector index (verified on 9.7); D1 loads no extensions and has no vector functions. A `$vector` sort throws on both, naming what is missing; use [Vectorize](https://developers.cloudflare.com/vectorize/) on Cloudflare. A `type: 'vector'` field still stores fine on MySQL, mapped to `JSON`. --- ## Query by Similarity Use `$sort` on a vector field with `$vector` and an optional `$distance` metric: ```ts title="You write" import { pool } from './uql.config.js'; const queryVec = await embed('How do I index a vector column?'); const results = await pool.findMany(Article, { $select: { id: true, title: true }, $sort: { embedding: { $vector: queryVec, $distance: 'cosine' } }, $limit: 10, }); ``` PostgreSQL / CockroachDB: ```sql SELECT "id", "title" FROM "Article" ORDER BY "embedding" <=> $1::vector LIMIT 10 ``` MariaDB: ```sql SELECT `id`, `title` FROM `Article` ORDER BY VEC_DISTANCE_COSINE(`embedding`, ?) LIMIT 10 ``` SQLite, sqlite-vec: ```sql SELECT `id`, `title` FROM `Article` ORDER BY vec_distance_cosine(`embedding`, ?) LIMIT 10 ``` libSQL / Turso: ```sql SELECT `id`, `title` FROM `Article` ORDER BY vector_distance_cos(`embedding`, ?) LIMIT 10 ``` MSSQL: ```sql SELECT "id", "title" FROM "Article" ORDER BY VECTOR_DISTANCE('cosine', "embedding", CAST(@p1 AS VECTOR(1536))) OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY ``` MongoDB Atlas: UQL translates into a [`$vectorSearch`](https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-stage/) aggregation pipeline: ```json [ { "$vectorSearch": { "index": "embedding_index", "path": "embedding", "queryVector": [/* queryEmbedding */], "numCandidates": 100, "limit": 10 } } ] ``` MongoDB vector search needs an Atlas cluster with a [vector search index](https://www.mongodb.com/docs/atlas/atlas-vector-search/create-index/) configured on the target field. The distance metric is defined in that index, so `$distance` is accepted for API consistency and ignored. ### Combined with Filtering Vector search composes naturally with `$where` and regular `$sort` fields: ```ts title="You write" const results = await pool.findMany(Article, { $where: { category: 'science' }, $sort: { embedding: { $vector: queryVec, $distance: 'cosine' }, title: 'asc', }, $limit: 10, }); ``` PostgreSQL / CockroachDB: ```sql SELECT * FROM "Article" WHERE "category" = $1 ORDER BY "embedding" <=> $2::vector, "title" ASC LIMIT 10 ``` MariaDB: ```sql SELECT * FROM `Article` WHERE `category` = ? ORDER BY VEC_DISTANCE_COSINE(`embedding`, ?), `title` ASC LIMIT 10 ``` SQLite, sqlite-vec: ```sql SELECT * FROM `Article` WHERE `category` = ? ORDER BY vec_distance_cosine(`embedding`, ?), `title` ASC LIMIT 10 ``` libSQL / Turso: ```sql SELECT * FROM `Article` WHERE `category` = ? ORDER BY vector_distance_cos(`embedding`, ?), `title` ASC LIMIT 10 ``` MSSQL: ```sql SELECT * FROM "Article" WHERE "category" = @p1 ORDER BY VECTOR_DISTANCE('cosine', "embedding", CAST(@p2 AS VECTOR(1536))), "title" ASC OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY ``` MongoDB Atlas: `$where` is merged into the `$vectorSearch.filter` for optimal pre-filtering, and secondary sorts become a separate `$sort` stage: ```json [ { "$vectorSearch": { "index": "embedding_index", "path": "embedding", "queryVector": [/* queryVec */], "numCandidates": 100, "limit": 10, "filter": { "category": "science" } } }, { "$sort": { "title": 1 } } ] ``` UQL merges `$where` into `$vectorSearch.filter` rather than adding a separate `$match` stage, which is what Atlas recommends: the filter runs through the search index instead of scanning the collection after the vector search. --- ## Distance Predicate `$sort` ranks by distance; `$near` in `$where` **filters** by it, so “the closest ten” and “everything closer than 0.35” stay separate asks. Use it whenever a far-but-least-far row is worse than no row at all: RAG context, deduplication, match thresholds. ```ts title="You write" const results = await pool.findMany(Article, { $where: { embedding: { $near: { $vector: queryVec, $lt: 0.35 } } }, $limit: 10, }); ``` PostgreSQL / CockroachDB: ```sql SELECT * FROM "Article" WHERE "embedding" <=> $1::vector < $2 LIMIT 10 ``` MariaDB: ```sql SELECT * FROM `Article` WHERE VEC_DISTANCE_COSINE(`embedding`, ?) < ? LIMIT 10 ``` SQLite, sqlite-vec: ```sql SELECT * FROM `Article` WHERE vec_distance_cosine(`embedding`, ?) < ? LIMIT 10 ``` libSQL / Turso: ```sql SELECT * FROM `Article` WHERE vector_distance_cos(`embedding`, ?) < ? LIMIT 10 ``` MSSQL: SQL Server pages only an ordered statement, so an unsorted one gets a constant `ORDER BY`: ```sql SELECT * FROM "Article" WHERE VECTOR_DISTANCE('cosine', "embedding", CAST(@p1 AS VECTOR(1536))) < @p2 ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY ``` ### Bounds `$lt`, `$lte`, `$gt`, `$gte` and `$between`, the ordering comparisons. At least one is required: a `$near` carrying only a vector keeps every row, so it throws instead. There is no `$eq` or `$ne`, because a distance is a floating-point number and exact equality against one is a bug every time. ```ts // a band, for deduplication: close enough to be related, far enough not to be the same document const related = await pool.findMany(Article, { $where: { embedding: { $near: { $vector: queryVec, $between: [0.05, 0.4] } }, }, }); ``` Two bounds spell the distance twice in the SQL, since a `WHERE` has no output alias to point back at. ### Filter and rank together Each clause states its own search, so you can filter by similarity and order by anything else: ```ts // the relevant documents, newest first const results = await pool.findMany(Article, { $where: { category: 'docs', embedding: { $near: { $vector: queryVec, $lte: 0.4 } }, }, $sort: { createdAt: 'desc' }, }); ``` To do both on the same vector, name it once in a `const`. `$sort` keeps `$project`, so the score still comes back: ```ts import type { WithProjection } from 'uql-orm'; const queryVec = await embed('How do I paginate a query?'); const results = (await pool.findMany(Article, { $where: { embedding: { $near: { $vector: queryVec, $lt: 0.35 } } }, $sort: { embedding: { $vector: queryVec, $project: 'score' } }, $limit: 30, })) as WithProjection[]; ``` `$near` never borrows the `$sort`’s vector. That is what lets the same predicate mean the same thing where there is no `$sort` at all: in an entity filter merged into someone else’s `$where`, or in `exists`, which takes a filter and nothing else: ```ts // near-duplicate check before inserting await pool.exists(Article, { $where: { embedding: { $near: { $vector: queryVec, $lt: 0.05 } } }, }); ``` > **Not available on MongoDB Atlas** > > Atlas has no distance operator: it scores by the `similarity` set in the Atlas index definition, which UQL neither emits nor reads. Converting a distance bound to that scale would mean guessing which metric produced the score, and guessing wrong drops the wrong rows silently, so `$near` throws there. Project the score with `$project` and filter on it in your app instead. MySQL and D1 throw the same way they do for a vector `$sort`. --- ## Distance Metrics `cosine` suits text embeddings (OpenAI, Cohere), `l2` image search and spatial data, `inner` maximum inner product, and `l1` Manhattan distance. Each engine computes them with: | Engine | `cosine` | `l2` | `inner` | `l1` | | - | - | - | - | - | | Postgres | `<=>` | `<->` | `<#>` | `<+>` | | CockroachDB | `<=>` | `<->` | `<#>` | ❌ | | MariaDB | `VEC_DISTANCE_COSINE` | `VEC_DISTANCE_EUCLIDEAN` | ❌ | ❌ | | SQLite (sqlite-vec) | `vec_distance_cosine` | `vec_distance_L2` | ❌ | `vec_distance_L1` | | libSQL | `vector_distance_cos` | `vector_distance_l2` | ❌ | ❌ | | Turso | `vector_distance_cos` | `vector_distance_l2` | `vector_distance_dot` | ❌ | | MSSQL | `VECTOR_DISTANCE('cosine')` | `VECTOR_DISTANCE('euclidean')` | `VECTOR_DISTANCE('dot')` | ❌ | | MongoDB Atlas (index-defined) | ✅ | ✅ | ✅ | ❌ | Any metric marked ❌ throws at query build time on that dialect, naming the metric, rather than reaching the database as a call to a function it does not have. `l1` is [not yet implemented](https://www.cockroachlabs.com/docs/stable/vector-indexes) on CockroachDB, and `inner` needs Turso’s Rust engine (`vector_distance_dot`), which no libSQL build has. That makes it `uql-orm/turso/local`’s alone: a Turso Cloud database may run libSQL, so `uql-orm/turso` refuses it. If omitted, `$distance` defaults to `'cosine'`. You can also set a default per-field: ```ts @Field({ type: 'vector', dimensions: 1536, distance: 'l2' }) embedding?: number[] | null; ``` Queries on this field use `l2` unless overridden with `$distance` at query time, in `$sort` or in `$near`. A field that names no metric takes its vector index’s, since an index serves only the metric it was built for, and `'cosine'` only where it has no index either. --- ## Distance Projection Project the computed distance as a named field in the result with `$project`: ```ts title="You write" import type { WithProjection } from 'uql-orm'; const results = (await pool.findMany(Article, { $select: { id: true, title: true }, $sort: { embedding: { $vector: queryVec, $distance: 'cosine', $project: 'distance' }, }, $limit: 10, })) as WithProjection[]; results.forEach((r) => console.log(r.title, r.distance)); ``` PostgreSQL / CockroachDB: ```sql SELECT "id", "title", "embedding" <=> $1::vector AS "distance" FROM "Article" ORDER BY "distance" LIMIT 10 ``` MariaDB: ```sql SELECT `id`, `title`, VEC_DISTANCE_COSINE(`embedding`, ?) AS `distance` FROM `Article` ORDER BY `distance` LIMIT 10 ``` SQLite, sqlite-vec: ```sql SELECT `id`, `title`, vec_distance_cosine(`embedding`, ?) AS `distance` FROM `Article` ORDER BY `distance` LIMIT 10 ``` libSQL / Turso: ```sql SELECT `id`, `title`, vector_distance_cos(`embedding`, ?) AS `distance` FROM `Article` ORDER BY `distance` LIMIT 10 ``` MSSQL: ```sql SELECT "id", "title", VECTOR_DISTANCE('cosine', "embedding", CAST(@p1 AS VECTOR(1536))) AS "distance" FROM "Article" ORDER BY "distance" OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY ``` MongoDB Atlas: On MongoDB the score becomes a real field with `$addFields`, which the query’s own projection then keeps like any other: ```json [ { "$vectorSearch": { "index": "embedding_index", "path": "embedding", "queryVector": ["..."], "numCandidates": 100, "limit": 10 } }, { "$addFields": { "distance": { "$meta": "vectorSearchScore" } } }, { "$project": { "_id": 1, "title": 1, "distance": 1 } } ] ``` Adding it rather than projecting it is what lets a query name no columns at all and still get whole documents back, each with its score. Find methods return the plain entity, so annotate the result with the exported `WithProjection` helper to type the projected `distance` field. The projection costs nothing extra: on SQL dialects `ORDER BY` references the projected alias instead of recomputing the distance expression, and on MongoDB Atlas returns the score through `$meta`. ## Ranking by a Related Row A `$vector` under a relation ranks each row by its related row nearest the vector: the smallest of their distances. A guide split into passages ranks by its best passage: ```ts title="You write" import { Entity, Field, Id, ManyToOne, OneToMany } from 'uql-orm'; @Entity() export class Guide { @Id({ type: Number }) id?: number; @Field({ type: String }) title?: string | null; @OneToMany({ entity: () => Passage, mappedBy: (passage) => passage.guide }) passages?: Passage[]; } @Entity() export class Passage { @Id({ type: Number }) id?: number; @Field({ references: () => Guide }) guideId?: number | null; @ManyToOne({ entity: () => Guide, references: (passage) => passage.guideId }) guide?: Guide; @Field({ type: 'vector', dimensions: 1536 }) embedding?: number[] | null; } ``` ```ts title="You write" import { pool } from './uql.config.js'; const queryVec = await embed('How do I index a vector column?'); const guides = await pool.findMany(Guide, { $select: { id: true, title: true }, $where: { title: { $istartsWith: 'indexing' } }, $sort: { passages: { embedding: { $vector: queryVec } } }, $limit: 10, }); ``` ```sql title="PostgreSQL" SELECT "id", "title" FROM "Guide" WHERE "title" ILIKE $1 ORDER BY (SELECT MIN("passages"."embedding" <=> $2::vector) FROM "Passage" "passages" WHERE "passages"."guideId" = "Guide"."id") LIMIT 10 ``` - It reads one-to-many, many-to-many and to-one relations alike, and needs no `$populate`. A many-to-many reads each target once, however many links pair it. - It ranks ahead of the other `$sort` keys, as a vector distance always does, and takes a `$distance`. It has nothing to `$project`, since no one related row answers under it. - A row with no related rows has no distance. Where it sorts follows the engine’s null ordering, so filter those rows out with `$where` when that matters. - Each row’s distance is computed over its own related rows, without the vector index. Narrow the rows with `$where` first, as above. - **MongoDB** computes it exactly in the aggregation pipeline, so it needs no Atlas cluster and honors `$distance`, unlike a root `$vector` sort. It ranks the queried entity’s relations, not a relation’s relations, as a `$count` sort does there. MySQL and D1 throw, as they do for any vector search. --- ## Vector Types UQL supports three vector storage types; use the one that best fits your model and performance needs: | Type | SQL (Postgres) | Storage | Max Dimensions | Use Case | | - | - | - | - | - | | `'vector'` | `VECTOR(n)` | 32-bit float | 2,000 | Standard embeddings (OpenAI, etc.) | | `'halfvec'` | `HALFVEC(n)` | 16-bit float | 4,000 | 50% storage savings, near-identical accuracy | | `'sparsevec'` | `SPARSEVEC(n)` | Sparse | 1,000,000 | SPLADE, BM25-style sparse retrieval | ```ts @Field({ type: 'vector', dimensions: 1536 }) // OpenAI ada-002 embedding?: number[] | null; @Field({ type: 'halfvec', dimensions: 1536 }) // Same model, half storage embedding?: number[] | null; @Field({ type: 'sparsevec', dimensions: 30000 }) // SPLADE sparse sparseEmbedding?: number[] | null; ``` `halfvec` and `sparsevec` come from pgvector, so they exist on Postgres alone. CockroachDB, MariaDB and MSSQL map them to their own `VECTOR` type and the SQLite family to `F32_BLOB`, casts included, so a `halfvec` field binds as `vector` on CockroachDB where the type does not exist. Whichever you declare, you hand UQL a dense `number[]`. For `sparsevec` it converts to pgvector’s sparse literal (`{1:1,3:2}/3`) on the way out, since that type rejects the dense form. An index on a narrower type gets the matching operator class (`halfvec_cosine_ops`). IVFFlat refuses `sparsevec` and `l1`, pgvector having no operator class for either, so use `hnsw` there. --- ## Vector Indexes Define vector indexes with `@Index()` for efficient approximate nearest-neighbor (ANN) search: | Index Type | Supported on | Notes | | - | - | - | | `hnsw` | Postgres (`USING hnsw` with operator classes), CockroachDB (its native vector index) | Best accuracy, higher memory | | `ivfflat` | Postgres (`USING ivfflat` with a `lists` param) | Faster build, large datasets | | `vector` | CockroachDB, MariaDB (`CREATE VECTOR INDEX`) | Each engine’s own native vector index | | any of those | libSQL and Turso Cloud (`libsql_vector_idx`, DiskANN) | Built in, no extension | | `vectorSearch` | MongoDB Atlas (an Atlas vector search index) | MongoDB’s managed ANN index | MySQL is absent from the table on purpose: it has no vector index, so any of these types throws when migrations are generated rather than emitting DDL the server rejects. MSSQL is absent too: its DiskANN index is still a preview feature, so a search there computes every distance, and migrations refuse any of these types for the same reason as on MySQL. Plain SQLite, the embedded Turso engine and D1 have none either: any of these types builds a plain index there, so a Postgres entity migrates unchanged, and a search computes every distance. ```ts title="Postgres HNSW" @Index((article) => [article.embedding], { type: 'hnsw', distance: 'cosine', m: 16, efConstruction: 64 }) ``` ```ts title="Postgres IVFFlat" @Index((article) => [article.embedding], { type: 'ivfflat', distance: 'l2', lists: 100 }) ``` ```ts title="CockroachDB" @Index((article) => [article.embedding], { type: 'vector', distance: 'cosine' }) ``` ```ts title="MariaDB" @Index((article) => [article.embedding], { type: 'vector', distance: 'cosine', m: 8 }) ``` ```ts title="MongoDB Atlas" @Index((article) => [article.embedding], { type: 'vectorSearch', name: 'my_search_index' }) ``` **CockroachDB** shares MariaDB’s `type: 'vector'` marker but emits a standalone `CREATE VECTOR INDEX "idx" ON "table" ("embedding" vector_cosine_ops)`, with no access-method keyword of the kind pgvector’s `USING ivfflat` / `USING hnsw` carries. `type: 'hnsw'` builds the same index, so a Postgres entity migrates there; `ivfflat` is refused. It covers `cosine`, `l2` and `inner` but not `l1`; `efConstruction` becomes its `build_beam_size`, and `m` is dropped. **libSQL and Turso Cloud** build `libsql_vector_idx` (DiskANN), with `m` as `max_neighbors` and `efConstruction` as `insert_l`, over one column, for `cosine` or `l2`, under a name of letters, digits and underscores. A ranked, paged search reads its nearest rows with `vector_top_k`, then orders them by their exact distance. **MongoDB** migrations create the Atlas index a `type: 'vectorSearch'` declares: the first field is the vector (its `@Field` dimensions, and `distance` as the similarity, cosine by default), the others are fields `$where` may pre-filter on. It is named `_index` unless you name it. Atlas builds it after the migration returns, and a server without Atlas Search refuses it. --- ## Tuning Recall An ANN index is approximate: it explores part of the graph and returns what it found, so a query can miss a row that is genuinely closer. `$candidates` widens that exploration for one query, trading speed for recall. ```ts title="You write" const results = await pool.transaction((querier) => querier.findMany(Article, { $sort: { embedding: { $vector: queryVec } }, $limit: 10, $candidates: 200, }), ); ``` The number is the **index’s own unit**, not a portable one, so it is not comparable across index types: | Engine | Index | Becomes | Engine default | | - | - | - | - | | PostgreSQL | `hnsw` | `SET LOCAL hnsw.ef_search` | 40 | | PostgreSQL | `ivfflat` | `SET LOCAL ivfflat.probes` | 1 | | CockroachDB | `hnsw`/`vector` | `SET LOCAL vector_search_beam_size` | 32 | | MariaDB | `vector` | `SET STATEMENT mhnsw_ef_search=N FOR ...` | 20 | | libSQL / Turso Cloud | any | `k` of `vector_top_k` | the page | | MongoDB Atlas | `vectorSearch` | `numCandidates` in the stage | 10x `$limit` | `$candidates` is ignored where there is nothing to widen: plain SQLite, the embedded Turso engine, D1 and MSSQL compute every distance, and a field carrying no ANN index is scanned exactly either way. It is also statement-level, like `$lock` - a populated relation’s rows are assembled after the ranking, so a relation’s own query has nothing to tune. > **Postgres and CockroachDB need an open transaction** > > `SET LOCAL` applies to the enclosing transaction and to nothing at all without one. Rather than let a query look tuned while running at the default recall, UQL refuses it; wrap the query in `pool.transaction(...)`. MariaDB scopes the variable to the single statement and MongoDB sets it on the stage, so neither needs one. ### Thresholds need it most A [distance predicate](#distance-predicate) on an HNSW index is where low recall becomes visible: the index hands back its candidate list and the predicate then removes from it, so you can get fewer rows than qualify. When a query combines `$near` with a vector `$sort`, UQL adds `hnsw.iterative_scan = strict_order` alongside `ef_search` so the scan keeps going until the limit is filled: `strict_order`, never `relaxed_order`, which would return rows out of distance order and contradict the `ORDER BY`. Nothing to opt into: pair a `$near` with a vector `$sort` and `$candidates`, as the [RAG shape above](#filter-and-rank-together) does, and you get it. --- ## Next Steps - [AI & RAG](https://uql-orm.dev/ai-semantic-search.md): End-to-end walkthrough: ingestion, querying, RAG thresholds. - [Indexes](https://uql-orm.dev/entities/indexes.md): Declaring `hnsw` / `ivfflat` vector indexes and their metric. - [Full-Text Search](https://uql-orm.dev/querying/full-text.md): Keyword search, and how to combine it with vectors. - [Querier API](https://uql-orm.dev/querying/querier.md): The full query API. # Transactions > Run a unit of work all-or-nothing in UQL, pick an isolation level, and decide who owns the connection and the commit. Source: https://uql-orm.dev/querying/transactions A transaction is a unit of work that either lands completely or not at all. UQL has three ways to run one, and they differ in exactly one thing: who owns the commit and who owns the connection. | | Commit / rollback | Connection | Called inside an active transaction | | - | - | - | - | | `pool.transaction(cb, opts?)` | UQL | UQL acquires and releases | takes a **fresh** querier, so a **separate** transaction | | `querier.transaction(cb, opts?)` | UQL | yours | joins it, no second `BEGIN` | | `beginTransaction` + `commit` / `rollback` | yours | yours | throws `pending transaction` | Start with the first. Reach for the others when you already hold a querier, or when the commit point is a decision rather than the end of a block. ## `pool.transaction()` Takes a connection, runs the callback in a transaction, commits on return, rolls back on throw, and releases either way: ```ts import { pool } from './uql.config.js'; import { Profile, User } from './shared/models/index.js'; const userId = await pool.transaction(async (querier) => { const id = await querier.insertOne(User, { name: 'Alice' }); await querier.insertOne(Profile, { userId: id, bio: '...' }); return id; }); ``` > **Everything atomic goes through the callback’s querier** > > [`pool.findMany(...)`](https://uql-orm.dev/querying/querier.md#choosing-poolx-vs-querierx), `pool.insertOne(...)` and [`pool.all(...)`](https://uql-orm.dev/querying/raw-sql.md#raw-sql-on-the-pool) acquire their **own** connection, so inside the callback they run *outside* the transaction: a read will not see its uncommitted changes, and a write will not be rolled back with it. Helpers that take a [`UniversalQuerier`](https://uql-orm.dev/querying/querier.md#accept-a-universalquerier) run on whatever the caller hands them, so the caller decides how much is atomic without any helper changing. ## `querier.transaction()` When you already hold a querier, this makes a *section* of its work atomic. It commits and rolls back; releasing stays with whoever acquired the connection: ```ts const userId = await pool.withQuerier(async (querier) => { // a read with nothing to roll back, on the same pinned connection const taken = await querier.count(User, { $where: { email } }); if (taken) { return undefined; } return querier.transaction(async () => { const id = await querier.insertOne(User, { name: 'Alice' }); await querier.insertOne(Profile, { userId: id, bio: '...' }); return id; }); }); ``` A `querier.transaction()` inside an active one joins it instead of opening a second, so a helper that is atomic on its own is safe to call from inside a larger transaction. The outermost call owns the commit, and a throw anywhere rolls back everything. ## Owning the connection yourself `await using` releases the querier when the block exits, however it exits, so an early return or a throw between acquiring and releasing cannot leak a connection: ```ts import { pool } from './uql.config.js'; import { Profile, User } from './shared/models/index.js'; async function registerUser(user: Partial, profile: Partial) { await using querier = await pool.getQuerier(); await querier.transaction(async () => { const userId = await querier.insertOne(User, user); await querier.insertOne(Profile, { ...profile, userId }); }); } ``` Every runtime UQL supports has it (Node 24+, Bun, Deno), and every current TypeScript setup downlevels it. A `try` / `finally` calling `querier.release()` is the same thing written out. Either way `transaction()` still handles commit and rollback; only the release is yours. Releasing with a transaction still open rolls it back and warns, so a path you forgot cannot strand a connection with a live `BEGIN` on it. Roll back explicitly where you meant to and the warning stays quiet, and if that rollback fails the connection is discarded instead of reused. A released querier is finished either way: using it again throws. ### Driving `begin` / `commit` / `rollback` Worth the extra lines only when the commit point is a decision: a shortfall, a stale precondition, a step in a saga. Rolling back is the *answer* there, not a failure, so there is no exception to throw at a callback. ```ts import { pool } from './uql.config.js'; import { Item, Order } from './shared/models/index.js'; async function placeOrder( itemId: number, quantity: number, customerId: number, ) { const querier = await pool.getQuerier(); try { await querier.beginTransaction(); const item = await querier.findOneById(Item, itemId, { $select: { stock: true, price: true }, $lock: true, }); const available = item?.stock ?? 0; if (available < quantity) { await querier.rollbackTransaction(); return { placed: false, available }; } await querier.updateOneById(Item, itemId, { stock: available - quantity }); await querier.insertOne(Order, { customerId, status: 'pending', amount: quantity * item!.price!, }); await querier.commitTransaction(); return { placed: true, available: available - quantity }; } catch (error) { await querier.rollbackTransaction(); throw error; } finally { await querier.release(); } } ``` The `catch` needs no `hasOpenTransaction` check: `rollbackTransaction()` does nothing when none is open, so a connection that failed on `beginTransaction` cannot report that instead of the real error. `commitTransaction()` is strict, because a caller who believes their work was committed has to hear that it was not. ## Isolation levels All three methods take an `isolationLevel`, which sets how much of other concurrent transactions this one can see: ```ts await pool.transaction( async (querier) => { /* ... */ }, { isolationLevel: 'serializable' }, ); ``` | Level | | | - | - | | `read uncommitted` | Dirty reads: can see uncommitted changes from other transactions. | | `read committed` | Only data committed before the query began. The default on most databases. | | `repeatable read` | Repeated reads inside the transaction return the same rows. | | `serializable` | Strictest: transactions behave as if they ran one after another. | PostgreSQL, MySQL, MariaDB, MSSQL and Bun SQL support all four. SQLite, LibSQL and MongoDB ignore the option: SQLite is serializable already, and MongoDB has no equivalent knob. A stricter level trades blocking for failing: two transactions that would conflict get a serialization failure or a deadlock instead, which [`queryErrorKind`](https://uql-orm.dev/querying/errors.md#retrying-a-transaction) names `retryable` on every engine, so the whole transaction can run again. Set it on each transaction that needs it rather than assuming a connection carries it. MySQL and MariaDB apply the level as a statement of its own ahead of `START TRANSACTION`, so if that `START TRANSACTION` then fails, the level stays applied to whatever the pooled connection runs next. ## Locking rows you are about to write On the default level, nothing stops two transactions reading the same row and both writing it. A read-modify-write needs the read itself to take a lock: ```ts await pool.transaction(async (querier) => { const item = await querier.findOneById(Item, id, { $select: { stock: true }, $lock: true, }); await querier.updateOneById(Item, id, { stock: item!.stock! - 1 }); }); ``` See [Row Locking](https://uql-orm.dev/querying/locking.md) for the wait policies, the `SKIP LOCKED` work-queue pattern, and which engines support it. ## Next Steps - [Pool vs. Querier](https://uql-orm.dev/querying/querier.md#choosing-poolx-vs-querierx): Which entry point a unit of work needs. - [Error Handling](https://uql-orm.dev/querying/errors.md): Retrying a transaction that failed on a conflict. - [Lifecycle Hooks](https://uql-orm.dev/entities/lifecycle-hooks.md): Hooks run on the same querier, inside your transaction. - [Raw SQL](https://uql-orm.dev/querying/raw-sql.md): Raw statements participate in the active transaction. - [Streaming](https://uql-orm.dev/querying/streaming.md): Long-lived reads and connection lifetime. # Error Handling > Tell a duplicate, a missing parent or a deadlock apart with one function, on every engine UQL supports. Source: https://uql-orm.dev/querying/errors A failed query throws the driver’s own error, so each engine names the same failure differently: a duplicate key is `23505` on PostgreSQL, `1062` on MySQL, `2627` on MSSQL, `11000` on MongoDB, and only a message on SQLite. `queryErrorKind(err)` reads whichever of those the error carries and names it once: ```ts import { queryErrorKind } from 'uql-orm'; import { pool } from './uql.config.js'; import { User } from './shared/models/index.js'; export async function signUp(email: string, password: string) { try { return await pool.insertOne(User, { email, password }); } catch (err) { if (queryErrorKind(err) === 'uniqueViolation') { throw Object.assign(new Error('That email is taken', { cause: err }), { status: 409, }); } throw err; } } ``` It only reads the error, never changes it: `instanceof` your driver’s error class and its own `code` keep working, and it answers for any driver error, not only ones a querier threw. It answers for the errors UQL raises itself too - a stale version, a call the API cannot carry out - so one function classifies every failure and nothing has to know which class it caught. ## Kinds | Kind | What happened | | - | - | | `uniqueViolation` | A primary key or unique index already holds the value. | | `foreignKeyViolation` | The referenced row is missing, or a row still referenced was deleted. | | `notNullViolation` | A required column got no value. | | `checkViolation` | A check constraint, or MongoDB schema validation, rejected the row. | | `optimisticLock` | An update carried a version the row has moved past, or the row is gone. | | `retryable` | A deadlock, serialization failure, lock timeout or busy database: run it again. | | `usage` | The call itself is wrong: an update with no version, a `$lock` with no transaction. | | `undefined` | Anything else: a syntax error, a lost connection, an error that is not the database’s. | UQL raises two errors of its own, each stating the kind above and the HTTP status it deserves: `UqlOptimisticLockError` (`optimisticLock`, `409`), which also carries the `expected` and `actual` version, and `UqlUsageError` (`usage`, `400`), a `TypeError` so an existing catch still sees one. Catch them by kind; reach for `instanceof` only to read `expected` and `actual`. ## Retrying a transaction A `retryable` failure rolled the transaction back, so running the whole callback again is safe. Retry the transaction, never a single statement inside it: ```ts import { queryErrorKind } from 'uql-orm'; import { pool } from './uql.config.js'; import { Item } from './shared/models/index.js'; async function withRetry(work: () => Promise, attempts = 3): Promise { for (let attempt = 1; ; attempt++) { try { return await work(); } catch (err) { if (attempt === attempts || queryErrorKind(err) !== 'retryable') { throw err; } } } } export function takeOne(id: number) { return withRetry(() => pool.transaction( async (querier) => { const item = await querier.findOneById(Item, id, { $select: { stock: true }, }); await querier.updateOneById(Item, id, { stock: item!.stock! - 1 }); }, { isolationLevel: 'serializable' }, ), ); } ``` ## Over HTTP The [HTTP handlers](https://uql-orm.dev/http.md) answer a `uniqueViolation` or `foreignKeyViolation` with `409 Conflict` and a `notNullViolation` or `checkViolation` with `400 Bad Request`, where any other failure is a `500`. The message stays that generic, since the driver’s names your tables and constraints, and PostgreSQL’s echoes the value. An `optimisticLock` answers `409` and a `usage` `400`, each with its own message: UQL wrote those, so they name nothing of your schema. A numeric `status` a hook throws still wins, with its own message. ## Per engine The kind is read from the SQLSTATE on PostgreSQL, CockroachDB, Neon, PGlite and Bun SQL (`code`, or `errno`), from `errno` on MySQL and MariaDB, from `number` on MSSQL, from `code` and `errorLabels` on MongoDB, and from the message on SQLite, LibSQL, Turso and Cloudflare D1. The values each kind matches: | Engine | unique | foreign key | not null | check | retryable | | - | - | - | - | - | - | | PostgreSQL, CockroachDB, Neon, PGlite, Bun SQL | `23505` | `23503` | `23502` | `23514` | `40P01`, `40001`, `55P03` | | MySQL, MariaDB | `1062` | `1451`, `1452` | `1048`, `1364` | `3819`, `4025` | `1213`, `1205`, `3572` | | MSSQL | `2627`, `2601` | `547` naming a FOREIGN KEY | `515` | `547` naming a CHECK | `1205`, `1222`, `3960` | | MongoDB | `11000` | | | `121` | `112`, `TransientTransactionError` | | SQLite, LibSQL, Turso, Cloudflare D1 | `UNIQUE constraint failed` | `FOREIGN KEY constraint failed` | `NOT NULL constraint failed` | `CHECK constraint failed` | `database is locked` | ## Next Steps - [Transactions](https://uql-orm.dev/querying/transactions.md): Isolation levels, and who owns the commit you retry. - [Row Locking](https://uql-orm.dev/querying/locking.md): `NOWAIT`, whose failure is `retryable`. - [Optimistic Locking](https://uql-orm.dev/entities/optimistic-locking.md): a stale version, whose failure is `optimisticLock`. - [HTTP](https://uql-orm.dev/http.md): The error envelope the handlers answer with. # Row Locking > Take a row-level lock with $lock, so a read-modify-write cannot lose an update, and build a work queue with SKIP LOCKED. Source: https://uql-orm.dev/querying/locking Reading a row and writing it back are two separate statements. Another transaction can read the same row in between; both then write, the second write wins, and the first update is lost with no error anywhere. `$lock` closes that gap: it locks the rows a query returns and holds them until the transaction ends. ```ts await pool.transaction(async (querier) => { const item = await querier.findOneById(Item, id, { $select: { price: true }, $lock: true, }); await querier.updateOneById(Item, id, { price: item!.price! - 1 }); }); ``` Without the lock, two overlapping callers both read `price: 5` and both write `4`. With it, the second waits for the first to commit, then reads `4` and writes `3`. ## Requires a transaction Every engine accepts `FOR UPDATE` in autocommit and then releases the lock as the statement commits, before you can act on the rows: correct SQL that protects nothing. UQL rejects it rather than letting it look like it worked. ```ts // throws UqlUsageError: $lock requires an open transaction await pool.findMany(Item, { $lock: true }); ``` That covers `pool.findMany` and its siblings, which take their own auto-committing connection. Use the querier the transaction callback hands you. ## Wait policies What to do about a row someone else already holds: | `$lock` | A row someone else holds | | - | - | | `true` | wait for it | | `{ $wait: 'nowait' }` | fail immediately instead of waiting | | `{ $wait: 'skip' }` | leave it out of the result | | `false` | no lock, for a query built conditionally | With `nowait` the engine raises its own error, so the transaction rolls back unless you catch it. `queryErrorKind(err)` names it `'retryable'` on every engine - the same answer a deadlock or a lock timeout gets, and the same response: run the transaction again. See [Error kinds](https://uql-orm.dev/querying/errors.md). ## A work queue `{ $wait: 'skip' }` is what makes a queue on your database possible. Each worker takes the rows nobody else holds, so two workers never draw the same job: ```ts await pool.transaction(async (querier) => { const batch = await querier.findMany(Item, { $select: { id: true }, $where: { isActive: true }, $sort: { createdAt: 'asc' }, $limit: 10, $lock: { $wait: 'skip' }, }); for (const item of batch) { await querier.updateOneById(Item, item.id!, { isActive: false }); } return batch; }); ``` ```sql title="postgres" SELECT "id" FROM "Item" WHERE "isActive" = $1 ORDER BY "createdAt" ASC LIMIT 10 FOR UPDATE SKIP LOCKED ``` Expect fewer rows than `$limit` when other workers hold some. That is the feature working: ask for more than you need, or loop. ## What gets locked `$lock` locks rows of the queried entity and nothing else. A relation reached through `$populate` is not locked: ```ts await pool.transaction(async (querier) => { // the items are locked; the company reached through $populate is not await querier.findMany(Item, { $populate: { company: true }, $lock: true }); }); ``` A to-many relation is read in a subquery, which `FOR UPDATE` does not lock. A to-one is joined into the same statement, whether `$populate` asked for it or `$sort` needed it, and UQL narrows the lock to the queried table for you (`FOR UPDATE OF "Item"`): a bare lock over a `LEFT JOIN` is an error on PostgreSQL and silently locks the joined rows everywhere else. To lock a related row, query it directly. `$lock` belongs to a find, and the types keep it there: `count`, `update`, and `delete` do not accept it, and neither does a nested `$populate` query. ## Engine support | Engine | `$lock: true` | `$wait: 'skip'` / `'nowait'` | with a joined relation | | - | - | - | - | | PostgreSQL | ✅ | ✅ | ✅ | | PGlite | ✅ | ✅ | ✅ | | CockroachDB | ✅ | ✅ | ✅ | | MySQL | ✅ | ✅ | ✅ | | MariaDB | ✅ | ✅ | ❌ rejected: it has no `FOR ... OF`, so the lock would extend to the joined rows | | MSSQL | ✅ | ✅ | ✅ | | SQLite, libSQL, Turso, D1 | ❌ | ❌ | ❌ | | MongoDB | ❌ | ❌ | ❌ | PGlite emits every one of those the way Postgres does, but being single-connection it has no second transaction to contend with, so a lock there never actually blocks. [PGlite](https://uql-orm.dev/pglite.md#one-connection-and-what-follows-from-it) covers what that costs you. SQLite locks the whole database rather than individual rows, and MongoDB has no row lock to map onto, so both reject `$lock` in the same words instead of ignoring it. There the transaction is the whole of the concurrency control; on MongoDB, an atomic update such as `findOneAndUpdate` is the idiom. `$lock` is also rejected over the [HTTP transport](https://uql-orm.dev/http.md) with a `400`: each request runs on its own auto-committing connection, so a lock taken for one would be released before the response was written. ## Across requests `$lock` holds a row until the transaction ends, so it only reaches as far as a transaction does. A user who loads a form and saves it a minute later is two requests, and four backends have no row lock at all. There, guard the write with a version column: see [Optimistic Locking](https://uql-orm.dev/entities/optimistic-locking.md). The two compose, and cost nothing together: a versioned entity read under `$lock` cannot have moved on by the time you write it, so the version check simply passes. # Cursor Streaming > Process millions of rows with a stable memory footprint using native driver-level cursors. Source: https://uql-orm.dev/querying/streaming `findManyStream()` is for result sets too large to hold in memory. Rather than filling a TypeScript array, it returns an `AsyncIterable` that hands you each row as it arrives from the database. ## Basic Usage `findManyStream` takes the same query as `findMany`, populated relations and `$count` included, and runs no lifecycle hooks. ```ts import { pool } from './uql.config.js'; import { User } from './shared/models/index.js'; const results = pool.findManyStream(User, { $select: { id: true, email: true }, $where: { status: 'active' }, }); for await (const user of results) { // Process each user row-by-row console.log(`Processing: ${user.email}`); } ``` It also works [straight on the pool](https://uql-orm.dev/querying/querier.md#choosing-poolx-vs-querierx), which is the one pool call whose connection outlives the call: it is held for the whole iteration and released when the loop ends, or when a `break`/`throw` closes the iterator. Abandoning the iterator without closing it leaks that connection, so keep it inside a `for await`. ```ts import { pool } from './uql.config.js'; for await (const user of pool.findManyStream(User, { $where: { status: 'active' }, })) { console.log(user.email); } ``` ## Why use Streaming? Memory stays flat regardless of result size, since rows are processed as they arrive rather than buffered into an array. You also start handling the first row before the database finishes producing the last one, and because iteration drives the cursor, the database only sends rows as fast as your loop consumes them. ## Native Driver Implementation Each driver streams its own way: | Driver | Implementation | | - | - | | **PostgreSQL** (`pg`), **CockroachDB**, **Neon** | Client cursor via `pg-query-stream`. | | **Bun SQL** (`bun:sql`) on Postgres or CockroachDB | Server-side cursor: `DECLARE` / `FETCH FORWARD` / `CLOSE`. | | **PGlite** | Server-side cursor, the same way. | | **MySQL** (`mysql2`) | Result set streaming via `.stream()`. | | **MariaDB** (`mariadb`) | Native `queryStream()`. | | **MSSQL** (`mssql`) | The driver’s own stream, paused while the loop is behind. | | **SQLite** (`better-sqlite3`, `node:sqlite`, `bun:sqlite`) | Iteration via `.iterate()`. | | **Bun SQL** (`bun:sql`) on MySQL or MariaDB | Buffered: the whole result, then yielded row by row. | | **MongoDB** (`mongodb`) | Native MongoDB `Cursor`. | | **Turso Cloud** (`@tursodatabase/serverless`) | The statement’s cursor, as the server steps it. | | **LibSQL** / **D1** | Buffered: the whole result, then yielded row by row. | A **server-side cursor** is what a driver with no cursor API of its own gets: `bun:sql` exposes none ([oven-sh/bun#17181](https://github.com/oven-sh/bun/issues/17181)) and neither does PGlite’s client, so the rows are paged in SQL instead, 100 at a time. `DECLARE` is only legal inside a transaction, so the stream opens one when the caller has none and ends it with the loop; inside your own transaction it just declares the cursor and leaves the transaction alone. A **buffered** driver holds the full result in memory before yielding, so `findManyStream` is an API convenience there, not a memory one: those engines have no cursor the client can reach. > **The connection is held for the whole loop** > > Streaming holds a database connection open for the duration of the loop. Keep the processing logic inside the `for await` loop fast; if each row needs heavy work, push it to a task queue and let the loop move on. --- ## Next Steps - [Querier API](https://uql-orm.dev/querying/querier.md): `findMany` and the rest of the read API. - [Deep Relations](https://uql-orm.dev/querying/relations.md): What `$populate` loads into each streamed row. - [Transactions](https://uql-orm.dev/querying/transactions.md): Holding a connection open for the duration of a stream. - [Aggregate Queries](https://uql-orm.dev/querying/aggregate.md): Let the database reduce the rows instead. # Query Filters > Named, default-on $where fragments for soft-delete, multi-tenancy, and row-level security. Source: https://uql-orm.dev/querying/filters A **filter** is a named `$where` fragment attached to an entity and applied to every query unless bypassed. It’s UQL’s equivalent of EF Core global query filters or Eloquent global scopes. [Soft-delete](https://uql-orm.dev/entities/soft-delete.md) is the built-in example; you can define your own for visibility flags, multi-tenancy, and more. ## Defining a filter Use the `@Filter` decorator, the `@Entity({ filters })` option, or `defineFilter`: ```ts import { Entity, Id, Field, Filter } from 'uql-orm'; @Filter('active', { where: { status: 'active' }, default: false }) @Entity() export class Task { @Id({ type: Number }) id?: number; @Field({ type: String }) status?: string | null; } ``` - **`where`**: a `$where` fragment, or a function returning one (see [context](#parameterized-filters--context)). - **`default`**: whether it applies unless bypassed. Defaults to `true`; set `false` for opt-in filters like `active` above. A default-on filter’s keys are only added when your `$where` doesn’t already mention them, so an explicit `$where` on that field opts out. The decorator-free equivalent, via `defineFilter` (see the [imperative API](https://uql-orm.dev/entities/imperative.md)): ```ts import { defineEntity, defineField, defineFilter, defineId } from 'uql-orm'; class Task { id?: number; status?: string; } defineId(Task, 'id', { type: Number }); defineField(Task, 'status', { type: String }); defineFilter(Task, 'active', { where: { status: 'active' }, default: false, }); defineEntity(Task, {}); ``` ## Bypassing filters Every read/update/delete accepts `QueryOptions.filters`: ```ts import { pool } from './uql.config.js'; pool.findMany(Task, {}, { filters: false }); // disable all filters pool.findMany(Task, {}, { filters: { softDelete: false } }); // disable one pool.findMany(Task, {}, { filters: { active: true } }); // force-enable a default:false filter ``` ## Parameterized filters & context A filter’s `where` can be a function of an ambient **context** (e.g. the current tenant). Set it for a span with `withContext` and read it anywhere with `getContext()`. It propagates across `await`, `Promise.all` and transactions, but **not** into event callbacks (emitters, timers, queues); [event-driven pipelines](https://uql-orm.dev/multi-tenancy.md#event-driven-pipelines-emitters-timers-queues) covers those: ```ts import { withContext } from 'uql-orm'; @Filter('tenant', { // with no tenant, return `undefined` (instead of { companyId: undefined }) so the query throws instead of running unscoped where: (ctx) => ctx?.tenantId != null ? { companyId: ctx.tenantId } : undefined, security: true, }) @Entity() export class Invoice { @Id({ type: Number }) id?: number; @Field({ type: Number }) companyId?: number | null; @Field({ type: Number }) total?: number | null; } await withContext({ tenantId: 42 }, () => pool.findMany(Invoice, {})); // generates: ... WHERE (companyId = 42) ``` ## Security filters (row-level security) Mark a filter `security: true` to enforce tenant isolation. A security filter is **always applied** (`filters: false` and per-name bypass are ignored), **AND-merged** so a client `$where` on the same field can’t widen it, and **fails closed**: if its context is missing, the query throws `UqlSecurityError` instead of running unscoped. Over HTTP, the wire parser accepts query keys only, so a client cannot send `filters` or `context`. [Multi-tenancy](https://uql-orm.dev/multi-tenancy.md) covers it end to end: wiring the context per request, the `{}` escape hatch for trusted cross-tenant jobs, and filling the tenant column on insert. --- ## Next Steps - [Multi-tenancy & RLS](https://uql-orm.dev/multi-tenancy.md): The main use case for `security: true` filters. - [Soft Delete](https://uql-orm.dev/entities/soft-delete.md): The built-in filter every soft-deletable entity gets. - [Comparison Operators](https://uql-orm.dev/querying/comparison-operators.md): Filter conditions use the same operator set. - [Querier API](https://uql-orm.dev/querying/querier.md): Where `opts.filters` is passed. # Pool > The object every query runs on: which one to build, how big, how long it lives, and how to close it. Source: https://uql-orm.dev/pool Every query runs on a pool. It owns the connections and lends one to each unit of work. It is also the only part of UQL tied to your database server: entities, queries and [migrations](https://uql-orm.dev/migrations.md) stay the same whichever pool you build. Each driver has an entry point of its own, and each takes that driver’s own options verbatim. UQL’s [extra options](https://uql-orm.dev/logging.md) (logger, `slowQuery`, [`namingStrategy`](https://uql-orm.dev/naming-strategy.md), default [`schema`](https://uql-orm.dev/multiple-schemas.md), lifecycle `listeners`) are the last argument on all of them. ## One per process Build it once at module scope and export it. That module is also what the [`uql-migrate`](https://uql-orm.dev/migrations.md) CLI reads, so the app and the migrations connect the same way: ```ts title="uql.config.ts" import type { Config } from 'uql-orm'; import { PgQuerierPool } from 'uql-orm/postgres'; import { Post, User } from './shared/models/index.js'; export const pool = new PgQuerierPool({ connectionString: process.env.DATABASE_URL, max: 10, connectionTimeoutMillis: 5_000, }); export default { pool, entities: [User, Post] } satisfies Config; ``` Import this one instead of constructing another: a second pool doubles the connections without adding capacity. The exceptions are a dev server that re-runs modules on hot reload, which caches the pool on `globalThis` (see [Next.js](https://uql-orm.dev/nextjs.md)), and [serverless functions](https://uql-orm.dev/serverless.md). Constructing a pool opens no socket. On the pooled drivers (`pg`, `mysql2`, `mariadb`, Bun’s `SQL`) even `pool.getQuerier()` borrows nothing: the first statement checks a connection out and `release()` returns it. A wrong password or an unreachable host therefore surfaces at the first query. To fail at boot instead, run one there: ```ts import { pool } from './uql.config.js'; await pool.all('SELECT 1'); ``` ## Which pool | Entry point | Class | First argument | | - | - | - | | `uql-orm/postgres` | `PgQuerierPool` | `pg`’s `PoolConfig` | | `uql-orm/neon` | `NeonQuerierPool` | `@neondatabase/serverless`’s `PoolConfig` | | `uql-orm/cockroachdb` | `CrdbQuerierPool` | `pg`’s `PoolConfig` | | `uql-orm/mysql` | `MySql2QuerierPool` | `mysql2`’s `PoolOptions` | | `uql-orm/maria` | `MariadbQuerierPool` | `mariadb`’s pool config | | `uql-orm/mssql` | `MsSqlQuerierPool` | `mssql`’s `config` | | `uql-orm/bunSql` | `BunSqlQuerierPool` | Bun’s `SQL.Options` | | `uql-orm/sqlite` | `Sqlite3QuerierPool`, `NodeSqliteQuerierPool` | file path, then the driver’s options | | `uql-orm/pglite` | `PgliteQuerierPool` | data directory, then PGlite’s options | | `uql-orm/libsql` | `LibsqlQuerierPool` | `@libsql/client`’s `Config`, or a client you built | | `uql-orm/turso` | `TursoQuerierPool` | Turso Cloud settings | | `uql-orm/turso/local` | `TursoLocalQuerierPool` | file path, then the engine’s options | | `uql-orm/d1` | `D1QuerierPool` | the Worker’s D1 binding, or a session from it | | `uql-orm/mongo` | `MongodbQuerierPool` | connection URI, then `MongoClientOptions` | Not all of them pool. SQLite, PGlite and the embedded Turso engine keep one connection for the pool’s lifetime; D1 and Turso Cloud reach the database over `fetch()` and hold nothing. The methods are the same, but on a single connection queries serialize, and two queriers cannot hold independent transactions: a test that needs two open at once needs two databases. ## How big `max` (`connectionLimit` on `mysql2` and `mariadb`, `maxPoolSize` on MongoDB) caps the connections one process opens. Three numbers bound it, and the smallest wins: - **What the server allows.** Postgres’ `max_connections` divided by the number of processes that connect to it, leaving room for migrations and your own `psql`. - **What the process runs at once.** A pool larger than the concurrency above it holds idle sockets and nothing else. An HTTP server that answers 40 requests at a time with one query each wants tens; a worker that processes one job at a time wants two or three. - **What the database can do in parallel**, which is roughly cores and disk. Past that, connections queue inside the server rather than in your pool, where they are more expensive and harder to see. > **A pool call inside a transaction borrows a second connection** > > `pool.findMany` and every other method on the pool acquires a connection of its own. Calling one *inside* a `pool.transaction` callback holds two at once, and at `max: 1` it waits for the connection it is already holding: a deadlock that ends at `connectionTimeoutMillis`, or never without one. Inside the callback, run queries on the `querier` you were handed; see [pool.x vs. querier.x](https://uql-orm.dev/querying/querier.md#choosing-poolx-vs-querierx). Give it a timeout for acquiring a connection: `connectionTimeoutMillis` on the `pg` family, which waits forever without one; `connectTimeout` on `mysql2` and `acquireTimeout` on `mariadb`. Without one, a database that is asleep, unreachable or out of connections leaves every request hanging. When the number of processes alone can exhaust the server, put a pooler in front of the database instead of shrinking `max`: PgBouncer or RDS Proxy for self-managed Postgres, [Supavisor](https://uql-orm.dev/supabase.md) on Supabase, [Hyperdrive](https://uql-orm.dev/postgres.md#cloudflare-hyperdrive) on Workers. All of them are transaction-mode, so session state does not survive between statements: `SET LOCAL`, advisory locks and temp tables belong inside one [`transaction`](https://uql-orm.dev/querying/transactions.md) callback, and migrations run against the direct endpoint. ## Closing it `pool.end()` closes idle connections and waits for the ones still checked out, so call it after the server has stopped accepting requests: a connection comes back only when its unit of work finishes. ```ts import { pool } from './uql.config.js'; process.on('SIGTERM', async () => { await server.close(); // stop accepting first await pool.end(); }); ``` [NestJS](https://uql-orm.dev/nestjs.md) does this for you when `app.enableShutdownHooks()` is on. D1’s `end()` is a no-op, and a [Turso](https://uql-orm.dev/turso.md) pool built from a client you passed in will not close a client it does not own. In a [serverless function](https://uql-orm.dev/serverless.md) do not call it at all: the platform freezes the process as soon as the response is sent, so the close would not finish, and the database reclaims the connections when the sockets die. Treat it as the end of that pool’s life. The single-connection pools reopen on the next query, but `pg` and `mysql2` refuse to hand out another connection once ended. ## When a connection dies while idle A NAT, a load balancer or the server’s own `idle_session_timeout` can drop a connection that is sitting in the pool. UQL listens for errors on the `pg`, Neon, CockroachDB, MariaDB and SQL Server driver pools, so the drop is logged through the pool’s [`logger`](https://uql-orm.dev/logging.md) (even when it is off) and the connection discarded, instead of an unhandled `'error'` event crashing the process. The next acquire opens a fresh one. The query in flight when the socket died is yours to retry: on a connection error only, and never a non-idempotent write outside a [transaction](https://uql-orm.dev/querying/transactions.md). The `pg` pools also set `keepAlive: true` by default, which makes drops less likely. A querier released with a transaction still open is rolled back and logged. If the rollback fails, the connection is discarded instead of going to the next borrower. ## More than one pool Two cases justify a second pool: a read replica, and a database per tenant. ```ts title="db.ts" import { PgQuerierPool } from 'uql-orm/postgres'; export const primary = new PgQuerierPool({ connectionString: process.env.DATABASE_URL, }); export const replica = new PgQuerierPool({ connectionString: process.env.REPLICA_URL, }); ``` Take the pool as a parameter instead of importing a fixed one. A [`UniversalQuerier`](https://uql-orm.dev/querying/querier.md#accept-a-universalquerier) accepts a pool or a querier, so the same function reads from the replica here and joins the caller’s transaction on the primary there: ```ts import type { UniversalQuerier } from 'uql-orm'; import { Invoice } from './shared/models/index.js'; const unpaid = (db: UniversalQuerier, companyId: number) => db.findMany(Invoice, { $where: { companyId, paid: false } }); await unpaid(replica, 42); await primary.transaction((querier) => unpaid(querier, 42)); ``` A database per tenant is a pool per tenant, built on demand and kept in a map. Cap that map and close what you evict, since every pool holds its own connections. The [HTTP handler](https://uql-orm.dev/http.md#the-pool) takes a function that picks the pool per request. Entities are registered once per process and every pool serves them. ## Reaching the driver `pool.pool` is the driver’s own pool on the `pg`, Neon, CockroachDB, `mysql2` and `mariadb` pools, for libraries that take a driver pool: `attachDatabasePool` from `@vercel/functions`, or a session store like `connect-pg-simple`. Bun’s pool exposes a `pg`-compatible shim under the same name; D1’s binding is `pool.db` and Bun’s `SQL` instance is `pool.sql`. `pool.dialect.dialectName` names the backend (`'postgres'`, `'cockroachdb'`, `'mysql'`, `'mariadb'`, `'mssql'`, `'sqlite'` or `'mongodb'`) for the rare code path that has to differ per database. ## In tests Swap the pool for one with no server behind it; entities and queries stay the same: [PGlite](https://uql-orm.dev/pglite.md) is Postgres itself compiled to WASM, and `Sqlite3QuerierPool(':memory:')` is a database per pool. ```ts title="test/db.ts" import { PgliteQuerierPool } from 'uql-orm/pglite'; export const pool = new PgliteQuerierPool(); ``` Build one per suite and `end()` it afterwards, or a live handle can keep the runner from exiting. Both are single-connection, so tests sharing a pool share a transaction scope; a test that needs a transaction of its own needs a pool of its own. # Migrations > Keep entities and your database schema in sync, in either direction, with the uql-migrate CLI. Source: https://uql-orm.dev/migrations Entities and tables have to agree, and UQL can start from either one: - **You write the entities.** UQL diffs your TypeScript classes against the live database and generates the migration SQL. No DDL by hand. See [from entities to the database](#from-entities-to-the-database). - **The database already exists.** UQL reads its tables, foreign keys and indexes and writes the `@Entity` classes for you. See [from a database to entities](#from-a-database-to-entities). Only the first step differs. Once the classes exist, they are the schema: every later change starts in an entity and reaches the database through a migration. ```bash # 1. Edit an entity: add a field, change a type, add a relation # 2. Generate the migration from the diff npx uql-migrate generate:entities add_user_nickname # 3. Review the file, then apply it npx uql-migrate up ``` Because the SQL comes out of the diff, entities and migrations cannot drift apart. On an empty database you can skip the file [and apply the diff directly](#syncing-without-a-migration-file), and [hand-written SQL](#plain-sql-migrations) stays available for data backfills and anything a diff cannot express. ## Configuration One `uql.config.ts` serves both your application bootstrap and the CLI, so migrations run under the same settings as your queries, [naming strategy](https://uql-orm.dev/naming-strategy.md) included. ```typescript title="uql.config.ts" import type { Config } from 'uql-orm'; import { PgQuerierPool } from 'uql-orm/postgres'; import { User, Post } from './entities'; export default { pool: new PgQuerierPool({ host: 'localhost', user: 'theUser', password: 'thePassword', database: 'theDatabase', }), entities: [User, Post], migrationsPath: './migrations', } satisfies Config; ``` There is no top-level `dialect` field: the CLI infers the engine from the pool you export, reading `pool.dialect.dialectName`. It looks for `uql.config.ts` in the project root unless you pass `--config` / `-c`. Under Bun, export a [`BunSqlQuerierPool`](https://uql-orm.dev/bun-sql.md) and run the CLI with `--bun`, so Bun resolves the TypeScript and loads its native drivers: ```bash bun run --bun uql-migrate up ``` ## CLI commands Writing schema changes: | Command | Description | | - | - | | `generate:entities ` | Diffs your entities against the database and writes the migration. | | `generate ` | Creates an empty timestamped file for SQL you write yourself, such as a data backfill. Aliased as `create`. | | `generate:from-db` | Scaffolds `@Entity` classes from an existing database, relations included. | Applying and inspecting: | Command | Description | | - | - | | `up` | Applies all pending migrations. | | `down` | Rolls back the last applied migration batch. | | `status` | Shows which migrations have run and which are pending. | | `pending` | Lists only the migrations still to be applied. | | `sync` | Applies the entity schema [directly to the database](#syncing-without-a-migration-file), with no file in between. | | `types` | Writes a `.d.ts` for the registered entities, for a [schema defined at runtime](https://uql-orm.dev/entities/imperative.md#a-schema-defined-at-runtime). | | `drift:check` | Reports where the [database and the entities disagree](#drift-detection). | Each command takes flags (`up --step`, `down --all`, `sync --dry-run`, `generate:from-db -o`); run `npx uql-migrate --help` for the full list. ## From entities to the database This is the everyday direction. You change a class, `generate:entities` turns the diff into a migration file, and `up` applies it: ```bash npx uql-migrate generate:entities add_articles_table npx uql-migrate up ``` The file holds the `CREATE TABLE` and `ALTER TABLE` statements the diff produced, in the dialect your pool speaks. It lands in `migrationsPath` for you to read before it reaches a database, and `down` takes it back out. ### Syncing without a migration file While the schema is still moving and the data is disposable (a prototype, a test database, a local container), `sync` applies the same diff directly: ```bash # Print the DDL your entities imply, without touching the database npx uql-migrate sync --dry-run # Apply it: creates the missing tables, columns, indexes, and foreign keys npx uql-migrate sync ``` It is **additive**: it creates what the entities declare and is missing, and refuses the destructive half of the diff (column drops, type alterations, dropping an index or dropping or altering a foreign key), so it is safe to re-run as the entities grow. `--unsafe` allows those, and `--force` drops and recreates every table your entities map, for resetting a scratch database only. Once there are rows you would miss, stop syncing in place and generate the migration instead: the same diff, but written to a file you review in the pull request and can roll back. ### From code, without the CLI `Migrator.sync` is the same engine the CLI calls, for a dev server or a test suite that sets its own schema up: ```ts import { Migrator } from 'uql-orm/migrate'; import config from './uql.config.js'; const migrator = new Migrator(config.pool, { entities: config.entities, }); // Automatically add missing tables and columns await migrator.sync({ logging: true }); ``` `sync({ entity })` applies one entity instead of every registered one, which is the path a [runtime schema](https://uql-orm.dev/entities/runtime.md) takes, and `planSync(options)` returns the statements without running them, as `--dry-run` prints them. Pass `entities` explicitly (`[User, Profile, Post]`) if the migrator does not share your `uql.config.ts`. Either way the classes have to be **imported** for `sync` to see them: an entity nothing references is an entity it will not create. ## From a database to entities When the tables came first (an existing product, another ORM, a schema someone else owns), point the CLI at the database and it writes the `@Entity` classes, then checks them back against what it read: ```bash npx uql-migrate generate:from-db --output ./src/entities npx uql-migrate drift:check ``` That is a one-time step. From here the entities are the schema like any other, and everything above applies unchanged. If your columns are `snake_case` and your code is `camelCase`, set the [naming strategy](https://uql-orm.dev/naming-strategy.md) in `uql.config.ts` before scaffolding, so the mapping covers the generated classes and your queries alike. Replacing another ORM as you go is covered at length in [switching to UQL](https://uql-orm.dev/switching-to-uql.md). ### Relations, when scaffolding `generate:from-db` reads relations off the constraints the database reports. A foreign key becomes `@ManyToOne` on the owning side and `@OneToMany` on the other; a foreign key that also carries a unique constraint becomes one-to-one. A table with no foreign key constraints therefore scaffolds without relations: junction tables come out as plain entities, and a column named like `user_id` comes out as a column. ## How the diff works One engine backs `generate:entities`, `sync` and `drift:check`, and it treats a schema as a graph rather than a list of tables: circular dependencies resolve, and tables are created and dropped in topological order. Types are compared per dialect, so equivalent spellings (`INTEGER` against `INT`) do not surface as phantom diffs. `generate:entities` and `sync` read only the tables your entities name, so a database shared with other applications costs no more to diff than your own tables. A table no entity names is never touched, and neither is a foreign key pointing at one. ### Drift detection `drift:check` compares the entities against the running database and reports two levels: - **Critical**: missing tables or columns, and type mismatches that risk truncating data. - **Warning**: missing indexes, unexpected columns, indexes that exist under the right name but no longer match what the entity declares, and foreign keys whose `ON DELETE` or `ON UPDATE` differs from it. The migrations table is left out of the comparison, since it exists by design and has no entity. Default values are not compared unless asked for: an engine reports a default as it stored it (`now()`, `CURRENT_TIMESTAMP`, `'active'::text`), which rarely matches the entity’s literal. ### Indexes, both directions Indexes travel each way: - **Entity -> DB**: `@Field({ index: true })` and `@Index(...)` create indexes with everything the engine supports: expressions, prefix lengths, stored order, `INCLUDE`, operator classes. A [type](https://uql-orm.dev/entities/indexes.md#index-types) or option the engine lacks is refused when the migration is generated. An index added to an existing table is created on the next sync. One the entity no longer declares, or whose declared columns changed, is dropped by a generated migration and by `--unsafe`, but only when uql named it (`
___idx`) or the entity still claims its name; an index named by hand is left alone. The generated `down` recreates it. - **DB -> Entity**: `generate:from-db` writes back what it reads: expressions, partial predicates, `INCLUDE` columns, access method and stored order. A plain single-column index becomes `@Field({ index })`; anything a field cannot express, a unique index included, becomes an `@Index`. - **Drift**: `drift:check` compares an index structurally (columns and their stored order, uniqueness, access method, `INCLUDE` columns, operator class) as far as the engine reports them. Postgres reports all of it, CockroachDB everything but nulls order and operator class, MySQL, MariaDB, MSSQL and SQLite only columns and uniqueness. Expressions and partial predicates are never compared, since a database reprints them from its parse tree and matching the two spellings would need a SQL parser. ### What the generated DDL guarantees - An auto-increment key is spelled from the type it declares: `@Id({ type: Number })` is a `BIGINT`, `columnType: 'int'` a four-byte one, so a foreign key can always match it. - Tables for SQLite, LibSQL and Cloudflare D1 are created in **STRICT mode**. - Primary keys are never altered automatically by `sync`. - Foreign key columns inherit the exact SQL type of the primary key they reference. ## Plain SQL migrations A migration is a module exporting `up`/`down`, the same shape `generate:entities` produces, so you can write the SQL yourself: ```typescript title="migrations/20260731120000_add_articles.ts" import type { SqlQuerier } from 'uql-orm/migrate'; export default { async up(querier: SqlQuerier): Promise { await querier.run( `CREATE TABLE "articles" ("id" BIGSERIAL PRIMARY KEY, "title" VARCHAR(200) NOT NULL)`, ); await querier.run( `CREATE INDEX "articles__title_idx" ON "articles" ("title")`, ); }, async down(querier: SqlQuerier): Promise { await querier.run(`DROP TABLE "articles"`); }, }; ``` One statement per `run` call, and the whole migration runs in a transaction on engines that support transactional DDL. A statement an engine refuses inside one, such as Postgres’s `CREATE INDEX CONCURRENTLY`, needs `transaction: false` beside `up` and `down`; a failure part-way then leaves the statements before it applied and the migration unlogged. Use this for anything the builder does not model (views, triggers, stored procedures, data backfills), or mix the two: `m.raw('...')` inside a builder migration takes plain SQL, and its `up`/`down` receive the querier as a second argument. The trade-off is portability: SQL you write is yours to keep working on every engine you target, while the builder emits the right dialect for each. Files are loaded as modules, so they must be `.ts`, `.js` or `.mjs`; a bare `.sql` file is not picked up. On MongoDB the same module takes a `MongoQuerier`; see [MongoDB migrations](https://uql-orm.dev/mongodb.md#migrations). ## Migration builder Instead of SQL strings, a hand-written migration can define its schema with a fluent, dialect-aware builder: `createTable`, `alterTable`, a method per column type, and `m.raw()` for the rest. See the [migration builder reference](https://uql-orm.dev/migrations/builder.md). # Migration builder > Fluent, dialect-aware table and column definitions for hand-written UQL migrations. Source: https://uql-orm.dev/migrations/builder A migration from `uql-migrate generate` can define its schema with a fluent builder instead of [SQL strings](https://uql-orm.dev/migrations.md#plain-sql-migrations). The builder emits each engine’s own dialect, so one migration runs on Postgres and SQLite unchanged. On MongoDB it takes collections and their indexes, see [MongoDB migrations](https://uql-orm.dev/mongodb.md#migrations). ## A typical migration Add a table with a relation and a composite index, and modify an existing one: ```typescript import { defineBuilderMigration, expr } from 'uql-orm/migrate'; export default defineBuilderMigration({ async up(m) { await m.createTable('articles', (t) => { t.id(); // BIGINT auto-increment PK t.string('title', { length: 200 }); // VARCHAR(200) NOT NULL t.string('slug', { length: 200, unique: true }); t.text('body'); t.boolean('published', { defaultValue: false }); t.timestamp('published_at', { nullable: true }); t.timestamp('created_at', { defaultValue: expr.now() }); t.integer('author_id', { references: { table: 'users', column: 'id', onDelete: 'CASCADE' }, }); t.index(['published', 'created_at']); }); await m.alterTable('users', (t) => { t.addColumn((c) => c.text('bio')); t.addColumn((c) => c.string('avatar_url', { length: 500, nullable: true }), ); t.addIndex(['email']); }); }, async down(m) { await m.alterTable('users', (t) => { t.dropIndex('users__email_idx'); t.dropColumn('avatar_url'); t.dropColumn('bio'); }); await m.dropTable('articles'); }, }); ``` ## Column types Every method takes the column name first and the [options object](#column-options) second. | Method | Notes | | - | - | | `id()` | Auto-incrementing `BIGINT` primary key; the name defaults to `id` | | `integer` | | | `smallint` | | | `bigint` | Defaults take a `BigInt` literal (`0n`) | | `float` | | | `double` | | | `decimal` | Takes `precision` and `scale` | | `string` | `VARCHAR`, `length` defaults to 255 | | `char` | `CHAR`, `length` defaults to 1 | | `text` | | | `boolean` | | | `date` | | | `time` | | | `timestamp` | Pair with `expr.now()` as a [default](#default-expressions) | | `timestamptz` | Timestamp with time zone | | `json` | | | `jsonb` | Binary JSON on Postgres, `json` elsewhere | | `uuid` | Pair with a [uuid default](#default-expressions), which is per-engine | | `blob` | | | `vector` | Takes `dimensions`, for [semantic search](https://uql-orm.dev/ai-semantic-search.md) | `createdAt()` and `updatedAt()` add a timestamp defaulting to `expr.now()`; `timestamps()` adds both. They name the columns literally, since the builder writes raw table columns and [naming strategies](https://uql-orm.dev/naming-strategy.md) only translate entity fields. Spell the names yourself if your tables are `snake_case`. Table-level constraints sit alongside the columns. `primaryKey` and `foreignKey` take a list, so they declare composite keys; a column-level `primaryKey: true` or `references` covers a single column: ```typescript await m.createTable('accounts', (t) => { t.string('tenant_id', { length: 40 }); t.string('username', { length: 50 }); t.string('email'); t.decimal('balance', { precision: 10, scale: 2 }); t.uuid('external_id'); t.primaryKey(['tenant_id', 'username']); t.foreignKey(['tenant_id']).references('tenants', ['id']).onDelete('CASCADE'); t.unique(['username', 'email']); t.index(['email']); t.comment('Customer accounts'); }); ``` Column methods also chain: `t.text('bio').nullable().comment('...')` says the same as the options object. Prefer the options object; it is what generated migrations use. Only `references('users').onDelete('CASCADE')` needs the chain. ## Column options | Option | Type | Default | Description | | - | - | - | - | | `nullable` | `boolean` | `false` | Allow NULL values | | `defaultValue` | `unknown` | `undefined` | Literal value, or an [expression](#default-expressions) | | `unique` | `boolean` | `false` | Add a unique constraint | | `primaryKey` | `boolean` | `false` | Mark as primary key | | `autoIncrement` | `boolean` | `false` | Enable auto-increment (integers only) | | `index` | `boolean` \| `string` | `false` | Create an index (bool auto-names it, string names it) | | `unsigned` | `boolean` | `false` | Unsigned numeric type, on MySQL and MariaDB | | `comment` | `string` | - | Database comment for the column | | `references` | `object` | - | Foreign key: `table`, `column`, `onDelete`, `onUpdate` | Note the inversion against [`@Field`](https://uql-orm.dev/entities/basic.md#field-options), where `nullable` defaults to `true`: a builder column is NOT NULL unless you say otherwise, an entity field is nullable unless you say otherwise. ### Default expressions `defaultValue` formats a literal for you (a string, number, boolean, `null`, a `Date`, a JSON object), so pass the value itself and UQL does the quoting and escaping. `expr` is for the defaults the database evaluates rather than stores: | Helper | Emits | Available | | - | - | - | | `expr.now()` | `CURRENT_TIMESTAMP` | everywhere | | `expr.currentDate()` | `CURRENT_DATE` | everywhere | | `expr.currentTime()` | `CURRENT_TIME` | everywhere | | `expr.uuid()` | `gen_random_uuid()`, `UUID()` | not SQLite | | `expr.uuidv7()` | `uuidv7()`, `UUID_v7()` | Postgres 18+, MariaDB 11.7+ | | `expr.onUpdateNow()` | `CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP` | MySQL, MariaDB | | `expr.raw(sql)` | whatever you write | everywhere | The dialect picks the spelling at generation time, so one migration works on every engine that has the function. Where an engine has none, generation throws instead of emitting SQL the server would reject. Use `expr.raw()` there. UQL cannot see server versions, so a Postgres 17 server takes `expr.uuidv7()` and rejects it itself. Worth that floor if you key on UUIDs: v7 is time-ordered, so inserts stay at the end of the index. ## Altering a table `alterTable` exposes `addColumn`, `dropColumn`, `renameColumn` and `alterColumn` for columns, `addIndex` and `dropIndex` for indexes, and `addForeignKey` and `dropForeignKey` for constraints: ```typescript await m.alterTable('users', (t) => { t.addColumn((c) => c.string('nickname', { length: 100 })); t.dropColumn('legacy_field'); t.renameColumn('full_name', 'name'); t.alterColumn((c) => c.string('email', { length: 300 })); t.addIndex(['nickname']); t.dropIndex('users__old_name_idx'); t.addForeignKey(['profile_id'], { table: 'profiles', columns: ['id'] }); t.dropForeignKey('users__legacy_fk'); }); // Escape hatch for anything the builder does not model await m.raw( 'CREATE VIEW active_users AS SELECT * FROM users WHERE is_active = true', ); ``` Each also exists on `m` with the table named first, for a migration that changes one thing: `m.addColumn('users', (c) => c.text('bio'))`. The remaining table operations live there too: ```typescript await m.renameTable('users', 'accounts'); await m.dropTable('legacy_sessions', { ifExists: true, cascade: true }); ``` ## Data backfills `up` and `down` receive the querier as their second argument, on the same connection and in the same transaction as the builder. Use it when a schema change needs the rows that are already there: ```typescript import { defineBuilderMigration } from 'uql-orm/migrate'; export default defineBuilderMigration({ async up(m, querier) { await m.addColumn('users', (c) => c.string('slug', { nullable: true })); const users = await querier.all<{ id: number; name: string }>( 'SELECT "id", "name" FROM "users"', ); for (const { id, name } of users) { await querier.run('UPDATE "users" SET "slug" = $1 WHERE "id" = $2', [ name.toLowerCase().replaceAll(' ', '-'), id, ]); } await m.alterColumn('users', (c) => c.string('slug')); }, async down(m) { await m.dropColumn('users', 'slug'); }, }); ``` Query tables by name, not through your entities: a migration is frozen at the schema it was written for, while an entity keeps changing after it. ## Index options `t.index(...)`, `t.unique(...)`, `t.addIndex(...)` and `m.createIndex(...)` take the same entries and options as the [`@Index` decorator](https://uql-orm.dev/entities/indexes.md): a column name, ``raw`...` ``for an expression, or an object with per-column modifiers, plus `type`, `where`, `include` and the vector tuning. A migration has no entity to check a predicate against, so `where` is `raw`. Both are rendered for the engine the migration runs on, with values written as literals. ```typescript import { raw } from 'uql-orm'; import { defineBuilderMigration } from 'uql-orm/migrate'; export default defineBuilderMigration({ async up(m) { await m.createTable('notes', (t) => { t.id(); t.string('email', { length: 200 }); t.text('body'); t.timestamp('deleted_at', { nullable: true }); // Case-insensitive uniqueness over live rows only t.unique([raw`lower("email")`], { name: 'notes__email_uk', where: raw`"deleted_at" IS NULL`, }); // MySQL and MariaDB require a prefix length to index TEXT at all t.index([{ column: 'body', length: 64 }]); }); await m.createIndex('notes', ['deleted_at'], { name: 'notes__deleted_idx', type: 'btree', }); }, async down(m) { await m.dropTable('notes'); }, }); ``` Options an engine cannot express throw when the migration runs, naming the index, as they do for entity-defined indexes. # Multi-tenancy > Scope every query to the current tenant automatically with security filters and request context. Source: https://uql-orm.dev/multi-tenancy UQL scopes queries to the current tenant with a **`security` [filter](https://uql-orm.dev/querying/filters.md)** whose condition reads a per-request **context**. Once set up, every read, update, and delete (relations and cascades included) is scoped automatically. You never write `WHERE tenantId = ...` by hand, and you can’t forget it. This page covers the common setup: tenants share a table, and a column keeps them apart. The alternative gives each tenant its own schema; see [Multiple Schemas](https://uql-orm.dev/multiple-schemas.md). Your entities are the same either way. ## 1. Mark the tenant filter `security` Its `where` is a function of the ambient context: ```ts import { Entity, Id, Field, Filter } from 'uql-orm'; @Filter('tenant', { // with no tenant, return `undefined` (instead of { companyId: undefined }) so the query throws instead of running unscoped where: (ctx) => ctx?.tenantId != null ? { companyId: ctx.tenantId } : undefined, security: true, }) @Entity() export class Invoice { @Id({ type: Number }) id?: number; @Field({ type: Number }) companyId?: number | null; @Field({ type: Number }) total?: number | null; } ``` Optionally type the context once so `ctx.tenantId` is typed everywhere. Keep the keys optional: a background sweep or a system job runs with only some of them set, and `withContext` takes whatever that unit of work knows. ```ts declare module 'uql-orm' { interface UqlContext { tenantId?: number; userId?: string; system?: boolean; } } ``` ## 2. Set the context for a unit of work `withContext` establishes the ambient context. It propagates across `await`, `Promise.all`, transactions and [pool calls](https://uql-orm.dev/querying/querier.md#choosing-poolx-vs-querierx), so one wrapper scopes a whole parallel fan-out: ```ts import { withContext } from 'uql-orm'; await withContext({ tenantId: 42 }, async () => { await pool.findMany(Invoice, { $where: { total: { $gt: 100 } } }); // Generated SQL: // SELECT ... FROM "Invoice" WHERE ("total" > $1) AND ("companyId" = $2) -- $2 = 42 await Promise.all([pool.findMany(Invoice, {}), pool.count(Invoice, {})]); // both scoped to tenant 42 }); ``` Wire it once at your HTTP boundary and every request is scoped. `getContext` receives the framework’s request; read the tenant from a **verified** source (a session store, or a decoded JWT), never from client input, which a caller could forge to reach another tenant: ```ts import { createRequestHandler } from 'uql-orm/http'; // framework-agnostic HTTP handler, typed with your framework's request: tenant from the session createRequestHandler<{ session: { tenantId: number } }>({ pool, getContext: (req) => ({ tenantId: req.session.tenantId }), }); // ...or from an auth layer that populated req.user (Passport, a guard, JWT middleware) createRequestHandler<{ user: { tenantId: number } }>({ pool, getContext: (req) => ({ tenantId: req.user.tenantId }), }); ``` ```ts import { UqlModule } from 'uql-orm/nestjs'; // NestJS (see the NestJS guide). `forRoot` is generic in your request type. type AuthedRequest = { user: { tenantId: number } }; UqlModule.forRoot({ pool, getContext: (req) => ({ tenantId: req.user.tenantId }), }); ``` ## What `security: true` guarantees - **Always applied**: `{ filters: false }` and per-name bypass are ignored for security filters. - **Can’t be widened by the client**: the condition is AND-merged as its own predicate, so a request sending `$where: { companyId: 999 }` becomes `companyId = 999 AND companyId = 42`, which matches no rows. - **Fails closed**: if the context is missing (`tenantId` undefined, so the condition returns `undefined`), the query throws `UqlSecurityError` instead of running unscoped. `onMissing: 'skip'` is rejected on security filters. - **Applies through relations too**: a `$populate: { company: true }` with no `$where` of its own still enforces `company`’s security filter, on every driver including MongoDB, and so do [relation filters and `$size` counts](https://uql-orm.dev/querying/relations.md), so a client-supplied threshold can’t count rows it may not read. - **Wire-safe over HTTP**: the request parser only accepts query keys, so a remote client can’t inject `filters`/`context` to bypass it. ## Combining with soft-delete Security and convenience filters compose. An `Invoice` that is also [soft-deletable](https://uql-orm.dev/entities/soft-delete.md) gets both predicates automatically: PostgreSQL: ```sql SELECT ... FROM "Invoice" WHERE ("companyId" = $1) AND ("deletedAt" IS NULL) ``` A cross-tenant admin task can bypass *convenience* filters but never the security one: ```ts import { withDeleted } from 'uql-orm'; pool.findMany(Invoice, {}, withDeleted()); // includes trashed, still tenant-scoped ``` ## Non-HTTP contexts (jobs, scripts, tests) Anything that isn’t an HTTP request just wraps its work in `withContext`: ```ts await withContext({ tenantId }, () => runNightlyBilling(tenantId)); ``` For trusted work that must span **all** tenants (startup recovery, cleanup sweeps), give the filter’s `where` a system branch that returns `{}` (“no restriction”) and run those jobs under an explicit system context. Queries with no context at all still fail closed: ```ts import type { QueryWhere, UqlContext } from 'uql-orm'; // The return type is what checks the columns: `FilterWhere` is a union, and annotating the // const with it instead lets a mistyped column through. const tenantScope = (ctx?: UqlContext): QueryWhere | undefined => ctx?.system ? {} : ctx?.tenantId != null ? { companyId: ctx.tenantId } : undefined; await withContext({ system: true }, () => recoverStaleJobs()); // spans every tenant, deliberately ``` ## Event-driven pipelines (emitters, timers, queues) `withContext` uses `AsyncLocalStorage`, which follows `await` chains but does **not** propagate into event-callback ticks: an emitter listener, a timer, or a queued job runs outside the scope that registered it. Two tools cover those boundaries: - **`captureContext()`**: capture once where the context exists, replay wherever the callback fires: ```ts const scoped = captureContext(); // e.g. when the session/queue item is created inside a scoped request deepgram.on('transcript', (t) => scoped(() => saveTranscript(t))); // runs with the captured context ``` - **`{ context }` per unit of work**: when pipeline code already knows its tenant (a `resource.tenantId` in hand), pass it where the querier is acquired, with no ambient wiring: ```ts await pool.withQuerier( (q) => q.updateMany(Resource, { $where: { id } }, patch), { context: { tenantId } }, ); ``` Pick by scope: `withContext` scopes a **span** (a request, a whole job), `{ context }` a **single pool call**, and `captureContext()` carries a span’s context across callbacks. Wrap your app’s few **chokepoints** (event bus, queue runners, socket dispatch) instead of every function; everything they await inherits the context. ## Filling the tenant column on insert Filters scope reads, updates, and deletes; inserts still need the tenant on the row. Fill it in `onInsert` from `getContext()`, the same ambient context `withContext` set, so payloads never mention it (an explicit value still wins). Throw when there is no tenant instead of inserting an unscoped row: ```ts import { getContext } from 'uql-orm'; @Field({ type: Number, references: () => Company, updatable: false, onInsert: () => { const tenantId = getContext()?.tenantId; if (tenantId === undefined) throw new Error('insert outside a tenant context'); return tenantId; }, }) companyId?: number | null; ``` > **Adopt fully: don’t run two scoping mechanisms** > > The tenant filter *replaces* hand-threading `tenantId` through every `$where` and insert. Migrate to it completely (filter, context wiring, `onInsert` fill, and delete the manual threading) or not at all; keeping both means two sources of truth for the same rule. ## App-level filters vs database-level RLS Security filters enforce tenancy in the ORM: they cover every UQL query (reads, writes, relations, cascades) and can’t be bypassed from the wire. They do not apply to raw SQL (`querier.all(...)`), which you scope by hand, and they only hold within this application. For the strongest isolation, pair them with **database-native row-level security** (e.g. Postgres RLS policies), which the database enforces regardless of app code or which service connects. The filter gives ergonomic, fail-closed scoping for everyday queries; RLS is the backstop. See [Query Filters](https://uql-orm.dev/querying/filters.md) for the full filter model (named, default-on, bypassable) that this builds on. # Multiple Schemas > Put entities in different Postgres schemas, or give each tenant a schema of its own, with the same entity classes. Source: https://uql-orm.dev/multiple-schemas A schema is a namespace inside one database (a *database*, in MySQL terms). UQL takes one in two places: on the entity, pinning a table wherever it lives, and on the pool, defaulting every entity that names none. The entity wins when both are set, and with neither, tables stay unqualified and resolve through the connection’s `search_path`. Qualified or not, entities join in a single statement. SQLite and MongoDB ignore `schema` entirely. ## A fixed layout: `schema` on the entity For a layout that doesn’t change per request, such as one schema per bounded context: ```ts import { Entity, Field, Id, ManyToOne } from 'uql-orm'; @Entity({ schema: 'crm' }) export class Customer { @Id({ type: Number }) id?: number; @Field({ type: String }) name?: string | null; } @Entity({ schema: 'sales' }) export class Order { @Id({ type: Number }) id?: number; @Field({ type: Number }) total?: number | null; @Field({ references: () => Customer }) customerId?: number | null; @ManyToOne({ entity: () => Customer, references: (order) => order.customerId, }) customer?: Customer; } ``` ```ts title="You write" import { pool } from './uql.config.js'; import { Order } from './shared/models/index.js'; const orders = await pool.findMany(Order, { $select: { id: true, total: true }, $populate: { customer: { $select: { name: true } } }, }); ``` PostgreSQL: ```sql SELECT "Order"."id", "Order"."total", "customer"."id" "customer.id", "customer"."name" "customer.name" FROM "sales"."Order" "Order" LEFT JOIN "crm"."Customer" "customer" ON "customer"."id" = "Order"."customerId" ``` > **The schema goes in schema, not in name** > > `name: 'sales.Order'` is rejected when the entity is defined. `name` escapes as one identifier, so the query would build fine and then fail at the database. ## A schema per tenant: `schema` on the pool When the schema changes per request, give each tenant a pool. The entity classes stay untouched: ```ts import { PgQuerierPool } from 'uql-orm/postgres'; const tenantA = new PgQuerierPool( { connectionString: process.env.DATABASE_URL }, { schema: 'tenant_a' }, ); const tenantB = new PgQuerierPool( { connectionString: process.env.DATABASE_URL }, { schema: 'tenant_b' }, ); ``` An entity with its own schema keeps it, so shared reference data can sit beside the per-tenant tables: ```ts @Entity() export class Invoice {} // wherever the pool points @Entity({ schema: 'public' }) export class Country {} // always public ``` UQL writes the schema into each statement rather than setting `search_path` on the session, so it survives a pooler that hands out a different session per transaction. ## Two entities, one table name A `Company` in two schemas is two classes with different TypeScript names mapping the same table name: ```ts @Entity({ schema: 'crm', name: 'Company' }) export class CrmCompany { @Id({ type: Number }) id?: number; @Field({ type: String }) label?: string | null; } @Entity({ schema: 'billing', name: 'Company' }) export class BillingCompany { @Id({ type: Number }) id?: number; @Field({ type: String }) vat?: string | null; } ``` Entities are keyed by the class, so nothing collides. The exception is [HTTP](https://uql-orm.dev/http.md), where the class name becomes the route (`/crm-company`, `/billing-company`); two classes landing on the same route make the handler throw at startup instead of silently dropping one. ## Over HTTP Pick the pool from the verified request context, never from the URL or query string, or any authenticated caller reads any tenant by editing a URL: ```ts import { createFetchHandler } from 'uql-orm/http'; const handler = createFetchHandler({ include: [Invoice], getContext: (request) => ({ tenantId: tenantOf(request) }), pool: (_request, { tenantId }) => poolFor(tenantId as string), }); ``` See [HTTP](https://uql-orm.dev/http.md) for mounting, and [Multi-tenancy](https://uql-orm.dev/multi-tenancy.md) for the other strategy: one shared schema with a `security` filter on a tenant column. ## Migrations `uql-migrate generate:entities` emits `CREATE SCHEMA IF NOT EXISTS` for each schema before the tables that go in it; MySQL and MariaDB read that as a database. `drift:check` and `sync` read back the schemas your entities name, so a qualified table diffs like any other. # Serverless > Run UQL in functions that freeze and thaw: pool placement, connection limits, and per-platform lifecycle. Source: https://uql-orm.dev/serverless A serverless function is a normal Node process with two differences that break the usual pooling advice: it is frozen between invocations and killed without warning, and there are as many processes as there is traffic. Only the [pool](https://uql-orm.dev/pool.md) changes; entities and queries do not. ## Put the pool at module scope Every platform reuses a warm instance for consecutive requests, and module scope is evaluated once per instance rather than once per request: ```ts title="db.ts" import { PgQuerierPool } from 'uql-orm/postgres'; import './entities.js'; export const pool = new PgQuerierPool({ connectionString: process.env.DATABASE_URL, max: 2, connectionTimeoutMillis: 5_000, }); ``` Building the pool inside the handler is the common mistake: it pays the TCP and TLS handshake every request and leaves the previous pool’s sockets to time out. Nothing connects until the first query, so a cold start is not charged for a pool it never uses. `connectionTimeoutMillis` is the one setting worth adding by default. Without it, a database that is asleep, unreachable or out of connections leaves the request waiting until the platform kills it, and you are billed for every second of that wait; with it, the query throws while there is still time to return a 503. A framework dev server is the opposite case: it re-runs the module on every hot reload, so cache the pool on `globalThis` in development (see [Next.js](https://uql-orm.dev/nextjs.md)). ## `max` is per instance, not per app `max: 10` on a platform that scales to 200 instances asks for 2000 connections; a small Postgres accepts about 100. What `max` should track is concurrency *inside* one instance. Lambda runs one invocation at a time per execution environment, so the only concurrency there is the one your handler starts itself: a `Promise.all` over three queries wants `max: 3`, and everything else wants `1` or `2`. The exceptions are runtimes that put several requests on one instance, such as Vercel Fluid compute, where a handful is right. Sizing that low has one trap: at `max: 1` a pool call made inside a `pool.transaction` callback [deadlocks](https://uql-orm.dev/pool.md#how-big) against the connection it is already holding. When instance count alone can exhaust the server, put a [pooler](https://uql-orm.dev/pool.md#how-big) in front of the database instead of shrinking `max`, and run [migrations](https://uql-orm.dev/migrations.md) against the direct endpoint. ## The first query after a thaw is the one that fails A frozen instance runs no timers, so nothing on your side observes a connection going away: `idleTimeoutMillis` cannot reap it, and the `keepAlive` that `PgQuerierPool` turns on by default sends no probes. The other end is under no such freeze. A database `idle_session_timeout`, a NAT, a load balancer or a pooler drops the socket while you are suspended, and the loss shows up as `ECONNRESET` or `Connection terminated unexpectedly` on the next invocation’s first query. UQL [discards](https://uql-orm.dev/pool.md#when-a-connection-dies-while-idle) the dead client and the next acquire opens a fresh one. The request in flight is yours to retry: on a connection error only, and never a non-idempotent write outside a [transaction](https://uql-orm.dev/querying/transactions.md). ## AWS Lambda Module scope survives between invocations for the life of the execution environment, so the snippet above is the whole setup. Do not call `pool.end()` per invocation: Lambda freezes the process as soon as the handler’s promise settles, so it would not finish. There is no shutdown hook worth wiring either, since the environment is torn down without running one and the database reclaims the connections when the sockets die. ```ts import type { APIGatewayProxyHandlerV2 } from 'aws-lambda'; import { pool } from './db.js'; import { Invoice } from './entities.js'; export const handler: APIGatewayProxyHandlerV2 = async () => { const invoices = await pool.findMany(Invoice, { $where: { paid: false }, $limit: 20, }); return { statusCode: 200, body: JSON.stringify(invoices) }; }; ``` A callback-style handler needs one flag that an `async` one does not: `context.callbackWaitsForEmptyEventLoop = false`. Otherwise Lambda holds the response until the event loop empties, and a pool with an idle socket in it never empties, so the invocation hangs to its timeout. If the function sits in a VPC to reach RDS, put RDS Proxy in that VPC too, or the instance count is the connection count. ## Vercel On Fluid compute an instance handles several concurrent requests and then suspends. `attachDatabasePool` releases idle clients before that, keeping the connection count proportional to traffic rather than to instance count: ```ts title="db.ts" import { attachDatabasePool } from '@vercel/functions'; import { PgQuerierPool } from 'uql-orm/postgres'; export const pool = new PgQuerierPool({ connectionString: process.env.DATABASE_URL, max: 5, }); attachDatabasePool(pool.pool); ``` [`pool.pool`](https://uql-orm.dev/pool.md#reaching-the-driver) is the underlying driver pool. The higher `max` covers the concurrent requests one instance serves. Keep the default Node.js runtime, since `pg` needs TCP. An [Astro](https://uql-orm.dev/astro.md#caching) app on this adapter has a cheaper option above the pool: with `cacheVercel()` as its cache provider, the repeat request is answered at the edge and never reaches a function or a connection. ## Neon `uql-orm/neon` swaps `pg` for `@neondatabase/serverless`, which carries the Postgres protocol over a WebSocket to Neon’s own proxy. That is what makes it work on runtimes where `pg` will not load at all, and it takes the same `PoolConfig`: ```ts title="db.ts" import { NeonQuerierPool } from 'uql-orm/neon'; import './entities.js'; export const pool = new NeonQuerierPool({ connectionString: process.env.DATABASE_URL, max: 2, }); ``` Everything above the pool is unchanged: same entities, same queries, same transactions. The driver takes the runtime’s global `WebSocket`, which Node 24, UQL’s floor, already has. A Neon compute that has scaled to zero still wakes on the first query, so the timeout budget from the first section applies here more than anywhere. ## Runtimes where a connection cannot outlive the request Cloudflare Workers, Vercel Edge and Deno Deploy hand out isolates, not processes. A socket opened while serving one request cannot be used to serve the next, and Workers enforces that: touching it from another request’s context throws. Module scope still runs once, so keep configuration and entity registration there, but build the pool inside the handler. ```ts title="src/index.ts" import { PgQuerierPool } from 'uql-orm/postgres'; import { Invoice } from './entities.js'; export default { async fetch(request: Request, env: Env, ctx: ExecutionContext) { const pool = new PgQuerierPool({ connectionString: env.HYPERDRIVE.connectionString, max: 5, }); try { const invoices = await pool.findMany(Invoice, { $where: { paid: false }, $limit: 20, }); return Response.json(invoices); } finally { ctx.waitUntil(pool.end()); } }, }; ``` `ctx.waitUntil` rather than `await`: the client is not waiting for a socket to close, so the response should not be either. Which driver goes in there is the real decision, and [Hyperdrive](https://uql-orm.dev/postgres.md#cloudflare-hyperdrive) is only one answer: [D1](https://uql-orm.dev/cloudflare-d1.md) and [Turso](https://uql-orm.dev/turso.md) reach their database over `fetch()`, where a pool holds nothing, `end()` is a no-op and the whole question disappears. Serving entities over the [HTTP transport](https://uql-orm.dev/http.md) works the same way here: the handler takes its `pool` as an option, and a function form picks one per request, so a Worker that serves a database per tenant needs nothing special. ## Cold starts The first request to a new instance pays for the module graph, the first connection, and, on a database that scales to zero, the wake-up. Only the first is yours to shrink: import the entry point you use (`uql-orm/postgres`, not a barrel that pulls in every dialect) and register only the entities the function needs. # Logging & Monitoring > Configure query logging, slow-query alerts, and custom loggers in UQL. Source: https://uql-orm.dev/logging UQL logs generated queries, per-query execution times, slow-query alerts, and migration activity. ## Configuration Logging is set per pool, usually in `uql.config.ts`. `logger: true` turns on every level through the built-in `DefaultLogger`: ```ts import { PgQuerierPool } from 'uql-orm/postgres'; export const pool = new PgQuerierPool( {/* connection options */}, { // Enable all log levels with colored output logger: true, // Threshold in ms to log slow queries slowQuery: 200, }, ); ``` ### Advanced Configuration An array enables levels selectively: ```ts import type { ExtraOptions } from 'uql-orm'; const options: ExtraOptions = { // Only log errors and warnings at the regular query level logger: ['error', 'warn'], // Independent of `logger`'s levels: any query at or past 200ms is logged as slow regardless slowQuery: 200, }; ``` `slowQuery` needs no matching entry in `logger`’s levels: setting the threshold turns slow-query alerts on, on top of whatever regular levels you enabled. For production, a common pattern is to go silent except for problems: ```ts const options: ExtraOptions = { // No regular query/info logging at all logger: ['error', 'warn', 'migration'], // ...but still alert on anything past a second slowQuery: 1000, }; ``` ### Including Bound Values in Logs Bound values are **never logged by default** (`logValues: false`), since query parameters may hold PII. Opt in explicitly where you want them, e.g. locally: ```ts const options: ExtraOptions = { logger: true, slowQuery: 500, logValues: true, }; ``` `logValues` applies to regular query logs and slow-query alerts alike. ## Log Levels | Level | Description | | - | - | | `query` | Each executed SQL statement/command, with its parameters and execution time. | | `error` / `warn` | Error traces and warnings. | | `migration` | Step-by-step history of schema changes. | | `skippedMigration` | Unsafe schema changes blocked during `sync`. | | `schema` / `info` | ORM initialization and sync events. | A driver pool’s idle-connection errors go through the same `logger`, and are reported even when it names no `error` level or is off: a swallowed one is the failure the listener exists to prevent. ## Output Format The `DefaultLogger` writes colored output like this: ```text query: SELECT * FROM "user" WHERE "id" = $1 -- [123] [2ms] slow query: UPDATE "post" SET "title" = $1 -- ["New Title"] [1250ms] error: Failed to connect to database: Connection timeout skipped migration: Cannot drop column "old_field" in safe mode ``` ## Custom Logger `logger` also takes a function, for the query level alone: ```ts { logger: (query, values, duration) => { console.log(`Executing ${query} with ${values}. Took ${duration}ms`); }; } ``` Or a class implementing `Logger`, whose methods (`logQuery`, `logSlowQuery`, `logWarn`, `logError`, `logInfo`, `logSchema`, `logMigration`, `logSkippedMigration`) are called independently, so slow queries can go somewhere regular ones do not: ```ts import type { Logger } from 'uql-orm'; class MyLogger implements Logger { logQuery(query: string, values?: unknown[], duration?: number) { console.log(`query: ${query}`, values, duration); } logSlowQuery(query: string, values?: unknown[], duration?: number) { pagerduty.alert(`Slow query (${duration}ms): ${query}`); } } const options: ExtraOptions = { logger: new MyLogger(), slowQuery: 500 }; ``` To keep `DefaultLogger`’s console output and only add alerting, extend it and override the one method: ```ts import { DefaultLogger } from 'uql-orm'; class AlertingLogger extends DefaultLogger { override logSlowQuery(query: string, values?: unknown[], duration?: number) { super.logSlowQuery(query, values, duration); // still print to console pagerduty.alert(`Slow query (${duration}ms): ${query}`); } } ``` # Naming Strategy > Configure global and per-entity naming strategies for tables and columns in UQL. Source: https://uql-orm.dev/naming-strategy ## Naming Strategies A naming strategy translates between your TypeScript code, usually `camelCase`, and your database schema, often `snake_case`. The database keeps its own conventions and your code stays idiomatic TypeScript, with nothing spelled twice. ### Built-in Strategies UQL comes with two built-in naming strategies: | Strategy | Behavior | | - | - | | `DefaultNamingStrategy` | Keeps names exactly as they are in your TypeScript code. | | `SnakeCaseNamingStrategy` | Converts `camelCase` to `snake_case`. | ### Using a Naming Strategy Set it on the pool in `uql.config.ts`; it applies to queries and [schema generation/migrations](https://uql-orm.dev/migrations.md) alike. Set it before [scaffolding entities from an existing database](https://uql-orm.dev/migrations.md#from-a-database-to-entities), so the generated classes carry the same mapping your queries will use. ```ts title="uql.config.ts" import { SnakeCaseNamingStrategy, type Config } from 'uql-orm'; import { PgQuerierPool } from 'uql-orm/postgres'; export const pool = new PgQuerierPool( { host: 'localhost', database: 'my_db' }, { // CamelCase -> snake_case translation namingStrategy: new SnakeCaseNamingStrategy(), }, ); export default { pool, migrationsPath: './migrations', } satisfies Config; ``` ### How it works When using `SnakeCaseNamingStrategy`: - **Entity**: `UserAccount` -> table `user_account` - **Field**: `createdAt` -> column `created_at` - **Relations**: `authorId` -> column `author_id` ### Custom Naming Strategy Implement `NamingStrategy`, or extend `DefaultNamingStrategy`: ```ts import { DefaultNamingStrategy } from 'uql-orm'; export class MyCustomNamingStrategy extends DefaultNamingStrategy { // Add a prefix to all table names override tableName(entityName: string): string { return `tbl_${super.tableName(entityName)}`; } // Force all column names to uppercase override columnName(propertyName: string): string { return propertyName.toUpperCase(); } } ``` # Bun Native SQL > Drive Bun's built-in SQL clients with UQL, one pool for Postgres, MySQL, MariaDB and CockroachDB. Source: https://uql-orm.dev/bun-sql Under Bun the driver dependency disappears: no `pg`, no `mysql2`. `uql-orm/bunSql` drives Bun’s own SQL clients through one pool, with the entities, queries and [migrations](https://uql-orm.dev/migrations.md) you already have. ```sh bun add uql-orm ``` ## Connect `BunSqlQuerierPool` takes Bun’s `SQL.Options` verbatim and infers the dialect from it, so the connection string is the only thing that changes between engines: ```ts import { BunSqlQuerierPool } from 'uql-orm/bunSql'; export const pool = new BunSqlQuerierPool({ url: 'postgres://localhost:5432/app', }); ``` | URL or option | Dialect UQL emits | Bun adapter | | - | - | - | | `postgres://`, `postgresql://` | PostgreSQL | `postgres` | | `mysql://` | MySQL or MariaDB, per the server | `mysql` | | CockroachDB connection URL | CockroachDB | `postgres` | The dialect UQL emits and the adapter Bun dials are two separate decisions. Bun’s `sql` client falls back to PostgreSQL, silently, whenever it does not recognize an adapter, so a misread URL produces Postgres syntax against a MySQL server; `BunSqlQuerierPool` normalizes the options per dialect instead. CockroachDB is the clearest case: Bun connects with its `postgres` adapter while UQL keeps the dialect id `cockroachdb` and goes on emitting [CockroachDB SQL](https://uql-orm.dev/cockroachdb.md). The SQL is the engine’s; only the binding is Bun’s. `BunSqlQuerierPool` builds the same `PostgresDialect` and `CockroachDialect` every other pool does, and hands them the wire driver’s capabilities: `( $N::text )::jsonb` where Bun needs the cast, and array literals for `ANY`/`ALL`, since Bun binds no array through a parameter. Without those a `$set` or `$push` on a `jsonb` column produces the wrong value or throws. You never select any of it: the pool reads the options and decides. ## Connections Bun exposes `.reserve()` to take a dedicated connection from its pool, so UQL reserves on acquire and releases when the querier is released. Raw SQL is `querier.all`/`querier.run` within a unit of work, or `pool.sql`, Bun’s own client, outside one; a querier does not expose that client, which would run a statement outside its transaction. ## SQLite Not through this pool, which refuses a SQLite URL or `filename`. [`Sqlite3QuerierPool`](https://uql-orm.dev/sqlite.md) reaches the file through `bun:sqlite` under Bun, installs nothing either, and streams, loads extensions and prepares statements, none of which `bun:sql`’s SQLite adapter can. ```ts import { Sqlite3QuerierPool } from 'uql-orm/sqlite'; const pool = new Sqlite3QuerierPool(':memory:'); ``` ## Streaming: real on Postgres, buffered elsewhere Bun’s client has no cursor API ([oven-sh/bun#17181](https://github.com/oven-sh/bun/issues/17181)), so `findManyStream` pages the rows in SQL instead (`DECLARE` / `FETCH FORWARD 100` / `CLOSE`) on **Postgres and CockroachDB**, where the engine has cursors. Memory stays flat, and the cursor is cleaned up when the loop ends, breaks or throws. `DECLARE` is only legal inside a transaction, so a stream started outside one opens its own and ends it with the loop. Inside your own `transaction(...)`, it declares the cursor there and leaves the transaction to you. On **MySQL and MariaDB** there is no cursor the client can reach, so the fallback runs: the full result is fetched, then yielded row by row, with the memory profile of `findMany`. Stream a large table on those engines with [`mysql2` or `mariadb`](https://uql-orm.dev/mysql.md) instead. See [Streaming](https://uql-orm.dev/querying/streaming.md). Bun’s own result objects are mapped to the standard shape, so `affectedRows`, `count` and `lastInsertRowid` arrive as the `changes`, `ids` and `firstId` every other driver reports. # Cloudflare D1 > Run UQL on Cloudflare Workers with D1, including its limits and the transaction it does not have. Source: https://uql-orm.dev/cloudflare-d1 D1 is SQLite at the edge, so entities, queries and generated SQL are the ones you would run on [SQLite](https://uql-orm.dev/sqlite.md). What changes is the runtime around it: the database arrives as a binding on `env`, and D1 has hard limits the dialect knows about. ```jsonc title="wrangler.jsonc" { "name": "my-app", "main": "src/index.ts", "compatibility_date": "2026-08-01", "d1_databases": [ { "binding": "DB", "database_name": "my-app", "database_id": "" }, ], } ``` ## Query from a Worker The binding only exists inside a request, so build the pool there. It is a thin wrapper over `env.DB`: nothing to connect, and `end()` is a no-op. ```ts title="src/models.ts" import { Entity, Id, Field } from 'uql-orm'; @Entity() export class Todo { @Id({ type: Number }) id?: number; @Field({ type: String }) title?: string | null; @Field({ type: Boolean }) completed?: boolean | null; } ``` ```ts title="src/index.ts" import { D1QuerierPool } from 'uql-orm/d1'; import { Todo } from './models'; export default { async fetch(request: Request, env: Env) { const pool = new D1QuerierPool(env.DB); const todos = await pool.findMany(Todo, { $where: { completed: false }, $limit: 50, }); return Response.json(todos); }, }; ``` Importing the entities module is what registers them, so keep that import even where a route does not name every entity. ## Read replication A replicated database is read through D1’s [Sessions API](https://developers.cloudflare.com/d1/best-practices/read-replication/); without it every query goes to the primary. Hand the pool a session, and every query of the request reads data at least as new as the writes before it. A bookmark carries that across requests: ```ts title="src/index.ts" export default { async fetch(request: Request, env: Env) { const session = env.DB.withSession( request.headers.get('x-d1-bookmark') ?? 'first-unconstrained', ); const pool = new D1QuerierPool(session); const response = Response.json(await pool.findMany(Todo, { $limit: 50 })); response.headers.set('x-d1-bookmark', session.getBookmark() ?? ''); return response; }, }; ``` ## D1 has no transactions D1 rejects `BEGIN TRANSACTION` with `D1_ERROR: not authorized`; a single statement is its only atomic unit. So `pool.transaction(...)` cannot work there, and UQL refuses one before sending anything, as it does the write routes of the [HTTP core](https://uql-orm.dev/http.md), which wrap every write in a transaction. Reads and single-statement writes work normally. When several writes must land together, model them as one statement, make them idempotent, or move that workload to a [Durable Object](https://developers.cloudflare.com/durable-objects/), whose storage API does have transactions. ## Limits | Limit | Value | | - | - | | Bound parameters per query | 100 (`insertMany` chunks to fit) | | Arguments per function call | 32 (wide calls are split to fit) | | Value or row size | 2 MB, a populated relation included | | SQL statement length | 100 KB | | Query duration | 30 s | | Database size | 10 GB on the paid plan | The parameter cap is the one that surprises people: 655 times smaller than Postgres’, so a bulk insert that is one statement elsewhere becomes many here. D1 also loads no extensions. FTS5 is available, so [full-text search](https://uql-orm.dev/querying/full-text.md) works through an FTS5 virtual table, but there are no vector functions and `sqlite-vec` cannot be loaded: a `$vector` sort throws, pointing at [Vectorize](https://developers.cloudflare.com/vectorize/). ## Schema changes The migrator needs a pool and a D1 binding only exists inside a Worker, so generate the DDL against a local SQLite pool and apply it with Wrangler. The SQL is identical; D1’s dialect differs only in the limits above. ```sh npx uql-migrate sync --dry-run # prints the SQL for your entities npx wrangler d1 migrations create my-app add_todos npx wrangler d1 migrations apply my-app --remote ``` ## Serving reads over HTTP `createFetchHandler` mounts natively. The binding arrives with the request rather than at module scope, and its write routes cannot work on D1: ```ts import { createFetchHandler } from 'uql-orm/http'; export default { fetch(request: Request, env: Env) { const handler = createFetchHandler({ pool: new D1QuerierPool(env.DB), include: [Todo], basePath: '/api', }); return handler(request); }, }; ``` For full SQLite semantics from the same runtime, including transactions, use [Turso](https://uql-orm.dev/turso.md); for Postgres, [Hyperdrive](https://uql-orm.dev/postgres.md#cloudflare-hyperdrive). # CockroachDB > Run UQL on CockroachDB over the Postgres wire protocol, and the differences that come with it. Source: https://uql-orm.dev/cockroachdb CockroachDB speaks the Postgres wire protocol, so UQL drives it with `pg` and shares the AST, quoting, JSONB, full-text and upsert logic with [PostgreSQL](https://uql-orm.dev/postgres.md). It is a separate dialect only because of the differences listed below. ```sh npm install uql-orm pg ``` ```ts import { CrdbQuerierPool } from 'uql-orm/cockroachdb'; export const pool = new CrdbQuerierPool({ connectionString: process.env.DATABASE_URL, max: 10, }); ``` ## Transactions retry The default isolation is `serializable`, and contention surfaces as a retryable `40001`. Retry the whole `transaction` callback so the new attempt starts from a fresh snapshot: ```ts const isRetryable = (err: unknown) => typeof err === 'object' && err !== null && 'code' in err && err.code === '40001'; ``` `read committed` is available on modern clusters and cuts most retries at the cost of the weaker guarantee: ```ts await pool.transaction(run, { isolationLevel: 'read committed' }); ``` ## Primary keys UQL’s generated surrogate key is `BIGINT GENERATED BY DEFAULT AS IDENTITY`. That works, but a monotonically increasing key concentrates every insert on one range. For a table under insert load, use a random UUID so writes spread: ```ts import { Id } from 'uql-orm'; @Id({ type: 'uuid', onInsert: () => crypto.randomUUID() }) id?: string; ``` Random, not time-ordered: a UUIDv7 sorts by time and rebuilds the hotspot it was meant to avoid. ## Differences from Postgres - **`created` on upserts** is always `undefined`: Postgres derives it from `xmax`, and CockroachDB’s transaction model has no equivalent system column, by design. - **Vector search** is native, so no `CREATE EXTENSION vector`, and indexes use `CREATE VECTOR INDEX` with no access-method keyword. Three of pgvector’s four metrics work (`cosine`, `l2`, `inner`); `l1` is unimplemented server-side. - **Index features** are narrower: expression and `INCLUDE` indexes work, `NULLS FIRST/LAST` and `jsonb_path_ops` do not. - **[Full-text search](https://uql-orm.dev/querying/full-text.md)** reads the search as plain words: CockroachDB has no `WEBSEARCH_TO_TSQUERY`. Everything else, native arrays, JSONB, `RETURNING` ids, [streaming](https://uql-orm.dev/querying/streaming.md) and [migrations](https://uql-orm.dev/migrations.md), behaves as on Postgres. Under Bun, [`bun:sql`](https://uql-orm.dev/bun-sql.md) reaches CockroachDB through its Postgres adapter while UQL keeps emitting CockroachDB SQL. # MongoDB > Run UQL on MongoDB with the same entities and queries you use on SQL, and where the two differ. Source: https://uql-orm.dev/mongodb MongoDB is a first-class backend, not a translation layer bolted on: the same entity classes and the same JSON query run there, and UQL compiles them to `find` cursors or aggregation pipelines instead of SQL. What follows is only what differs from the SQL dialects. ## Install and connect ```sh npm install uql-orm mongodb ``` ```ts import { MongodbQuerierPool } from 'uql-orm/mongo'; export const pool = new MongodbQuerierPool(process.env.MONGO_URL!, { maxPoolSize: 10, }); ``` The second argument is the driver’s `MongoClientOptions` verbatim (`maxPoolSize` is its cap on connections), and the third is UQL’s [extra options](https://uql-orm.dev/logging.md). The [pool’s lifecycle](https://uql-orm.dev/pool.md) is the same as on SQL; `pool.end()` closes the client. ## Ids MongoDB stores the primary key as `_id`; UQL maps it to whatever you named your `@Id` field, both ways: ```ts import { v7 as uuidv7 } from 'uuid'; import { Entity, Field, Id } from 'uql-orm'; @Entity() class User { @Id({ type: String, onInsert: uuidv7 }) id?: string; @Field({ type: String }) email?: string | null; } ``` An id is an `ObjectId` in the database and a **string** in your code: a 24-character hex string becomes an `ObjectId` going in and its hex string coming back. Anything else, a UUID or a number, is stored as given. Fields with `references` convert the same way. `$exclude: { id: true }` produces the `_id: 0` projection MongoDB requires. ### The key has to be one MongoDB can produce The only key a server generates is an `ObjectId`, so a key left to the database has to be declared a string: | The declaration | On MongoDB | | - | - | | `@Id({ type: String })` | the server mints an `ObjectId` | | `@Id({ type: String, onInsert: uuidv7 })` | you mint it, portable everywhere | | `@Id({ type: Number })` | **refused at the first write** | MongoDB cannot mint a number, and answering a `number` declaration with a string would be a lie. `uuidv7` is time-ordered, so inserts stay local in the `_id` index instead of scattering across it. ## Relations Relations compile to `$lookup` stages, so a read is one aggregation pipeline and [`$populate`](https://uql-orm.dev/querying/relations.md) behaves as on SQL. A query that only reads scalar fields skips the pipeline and uses a plain `find` cursor, which is the faster path. Filtering **on** a relation forces aggregation, since a cursor cannot express the join, and so do filtering by a [relation aggregate](https://uql-orm.dev/entities/computed-fields.md#relation-aggregates) and [ordering by one](https://uql-orm.dev/querying/relations.md#sorting-by-related-fields). `count` and `aggregate` filter the same way. A write’s filter hosts no `$lookup`, so an `updateMany` or `deleteMany` filtered by either reads the ids of the documents it names first. Ordering by a related field is the one place MongoDB asks for more than the SQL dialects do: the relation has to be populated as well as sorted by, at every level of the path. A `$lookup` adds a field to the result rather than being invisible the way a join is, so UQL will not add one your query did not ask for. Projection carries over to the aggregation path: `$select` and `$exclude` narrow the columns whether or not a relation is in play, and a relation’s own projection narrows it too. The `$project` stages are placed after the lookups have read the join keys, so nothing you populate is lost to a column you dropped. ## Computed fields A [relation aggregate](https://uql-orm.dev/entities/computed-fields.md#relation-aggregates) reads here as it does on SQL: the declaration is data, not SQL, so it compiles to a `$lookup` ending in a `$count` or a `$group`. A `computed` field writing SQL is the one form MongoDB has nothing to run. Naming one in `$select`, `$where` or `$sort` is refused; swept in with the rest of the entity’s fields it is skipped. ## Transactions need a replica set `querier.transaction(...)` opens a driver session and a real multi-document transaction, which MongoDB only supports on a replica set or a sharded cluster. Against a standalone `mongod` the server rejects it. A single-node replica set is enough in development: ```sh docker run -p 27017:27017 mongo --replSet rs0 --bind_ip_all # then, once: mongosh --eval 'rs.initiate()' ``` Isolation levels are a SQL concept, so `isolationLevel` is ignored here. ## Streaming `findManyStream` reads through the cursor `findMany` would use, a plain `find` or an aggregation pipeline, so a streamed row carries the relations a read would. See [Streaming](https://uql-orm.dev/querying/streaming.md). ## Search - **Full-text** needs a `text` index, which declares its own fields, weights and language: `$text` accepts `$fields` for API consistency and ignores it, and `$sort: { $text }` ranks by `textScore`. A `fulltext` `@Index` creates it. See [Full-text search](https://uql-orm.dev/querying/full-text.md). - **Vector search** uses Atlas `$vectorSearch` over the index a `type: 'vectorSearch'` `@Index` declares, which migrations create. `$candidates` maps to the stage’s `numCandidates`, but `$near` throws: Atlas scores by the index’s own `similarity`, not a distance, so a bound cannot be converted without guessing the metric. Project the score and filter on it. Ranking by a relation’s nearest row needs no Atlas: the distance is computed exactly in the pipeline, `$distance` included. See [Semantic search](https://uql-orm.dev/querying/semantic-search.md#ranking-by-a-related-row). - **Hybrid search** is native: MongoDB fuses a text match and a vector match with `$rankFusion`, where Postgres needs a hand-written reciprocal-rank-fusion query. ## Migrations [Migrations](https://uql-orm.dev/migrations.md) work with the same commands, written against the database handle instead of SQL, with the obvious caveat that a document store has no columns to alter: - `generate` scaffolds a migration typed on `MongoQuerier`, and `generate:entities` writes the collections and indexes your entities need as driver calls (`await querier.db.createCollection("users")`): `@Field({ index })` and `@Index`, a partial one’s `where` as its `partialFilterExpression` (see [Partial Indexes](https://uql-orm.dev/entities/indexes.md#partial-indexes)). - History lives in a `uql_migrations` collection, one document per migration keyed by its name. The config’s `tableName` renames it. - A migration runs outside any transaction, since MongoDB does not create collections or indexes inside one. One that fails halfway keeps what it already did and is not recorded, so write its steps to be safe to run again. - The [migration builder](https://uql-orm.dev/migrations/builder.md) takes `createTable` (a collection, its callback declaring indexes only), `dropTable`, `renameTable`, `createIndex` and `dropIndex`; a column, a foreign key or `raw` throws. ```typescript import { defineBuilderMigration, type MongoQuerier } from 'uql-orm/migrate'; export default defineBuilderMigration({ async up(m) { await m.renameTable('users', 'members'); await m.createIndex('members', ['email'], { unique: true }); }, async down(m) { await m.dropIndex('members', 'members__email_idx'); await m.renameTable('members', 'users'); }, }); ``` Field-level changes are data migrations you write yourself: ```typescript import { defineMigration, type MongoQuerier } from 'uql-orm/migrate'; export default defineMigration({ async up(querier) { await querier.db .collection('users') .updateMany({ active: { $exists: false } }, { $set: { active: true } }); }, async down(querier) { await querier.db .collection('users') .updateMany({}, { $unset: { active: '' } }); }, }); ``` The [migration builder](https://uql-orm.dev/migrations/builder.md) is SQL-only. # Microsoft SQL Server > Run UQL on SQL Server 2017 and up (MSSQL) with the mssql driver, and the behaviour that differs from the other engines. Source: https://uql-orm.dev/mssql Microsoft SQL Server 2017 and up, over the pure-JavaScript [`mssql`](https://www.npmjs.com/package/mssql) driver. 2017 is the floor because `STRING_AGG` is the newest T-SQL the dialect emits; everything else is 2016 or older. ## Connect ```sh npm install uql-orm mssql ``` ```ts import { MsSqlQuerierPool } from 'uql-orm/mssql'; export const pool = new MsSqlQuerierPool({ server: 'localhost', database: 'app', user: 'app', password: process.env.DB_PASSWORD, options: { encrypt: true, trustServerCertificate: false }, }); ``` The first argument is `mssql`’s own `config` verbatim. Sizing, lifetime and shutdown are the same on every driver: see [Pool](https://uql-orm.dev/pool.md). ## Snapshot isolation Set it, once, per database: ```sql title="MSSQL" ALTER DATABASE app SET READ_COMMITTED_SNAPSHOT ON WITH ROLLBACK IMMEDIATE; ``` SQL Server’s default `READ COMMITTED` takes shared read locks where PostgreSQL and MySQL use MVCC, so a concurrent ORM workload deadlocks on patterns that never deadlock elsewhere. This is the fix, and UQL’s own test database runs with it on. ## What differs from the other engines - **Strings are `NVARCHAR`**, always. `VARCHAR` is a codepage type that silently drops anything outside it, and UQL never exposes the choice. - **String comparison follows the database collation**, case-insensitive by default, as on MySQL. `$regex` matches case-sensitively regardless. - **`$regex` needs SQL Server 2025** at database compatibility level 170, where `REGEXP_LIKE` exists. Below that the server rejects the statement itself. - **`$text` is refused.** `CONTAINS` needs a full-text catalogue, which UQL does not create. - **Vector search is exact**, through `VECTOR_DISTANCE` on SQL Server 2025 with a declared `dimensions`. Its DiskANN index is still a preview feature, so every distance is computed rather than read from an index. - **Two cascade paths to one table are refused by the server** (error 1785), and so is `RESTRICT`, which T-SQL lacks. The default action, `NO ACTION`, meets neither. - **`expr.uuidv7()` throws.** `NEWSEQUENTIALID()` is an ordered v4 GUID with no readable timestamp, so it is not served in place of a v7. ## Generated ids `OUTPUT INSERTED` reports one id per row, so `insertMany` returns them exact, with no inference from a header as on MySQL. Writing an explicit value into an identity column works too: UQL wraps that insert in `SET IDENTITY_INSERT`, which the engine otherwise refuses. ## Migrations `uql-migrate` reads the schema back through `INFORMATION_SCHEMA` and the `sys` catalogue views, and diffs it like any other engine. A column’s default, `CHECK` and `UNIQUE` are constraints the server names itself, so dropping the column drops them first, and a retype puts the default back. Renames go through `sp_rename`. The one thing it will not do is create a table or index with `IF NOT EXISTS`, which T-SQL has no form of. # MySQL & MariaDB > Run UQL on MySQL with mysql2 or on MariaDB with its own driver, and the differences that matter. Source: https://uql-orm.dev/mysql MySQL and MariaDB share a dialect base but are two entry points with two drivers. Pick the one that matches the server you run: the generated SQL differs, and the MySQL dialect emits JSON operators MariaDB does not have. | Server | Entry point | Driver | | - | - | - | | MySQL 8.0.19+ | `uql-orm/mysql` | `mysql2` | | MariaDB 10.5+ | `uql-orm/maria` | `mariadb` | | Either, under Bun | [`uql-orm/bunSql`](https://uql-orm.dev/bun-sql.md) | built in | ## Connect ```sh npm install uql-orm mysql2 # or: mariadb ``` ```ts import { MySql2QuerierPool } from 'uql-orm/mysql'; // MariaDB: import { MariadbQuerierPool } from 'uql-orm/maria'; export const pool = new MySql2QuerierPool({ host: 'localhost', user: 'app', password: process.env.DB_PASSWORD, database: 'app', connectionLimit: 10, }); ``` Both take their driver’s own pool config verbatim. Sizing, lifetime and shutdown are the same on every driver: see [Pool](https://uql-orm.dev/pool.md). ## Generated ids after a multi-row insert The one behavioral difference worth knowing before choosing. MariaDB 10.5+ has `INSERT ... RETURNING`, so ids come back exact. MySQL has none: the driver reports only the first id and UQL infers the rest by incrementing it. ```ts const ids = await pool.insertMany(User, [ { email: 'a@example.com' }, { email: 'b@example.com' }, ]); ``` That inference holds under `innodb_autoinc_lock_mode` 0 or 1. MySQL 8 defaults to mode 2 (`interleaved`), where a concurrent insert into the same table can interleave with your statement’s allocation and leave the block non-contiguous. On a hot table, set lock mode 1 or insert row by row. ## What the dialect does - **Tables** are `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`; the surrogate key is `BIGINT AUTO_INCREMENT`, spelled from the type the `@Id` declares so a foreign key column can match it. - **Transactions** use `START TRANSACTION` with the isolation level set on the session just before. All four levels work; the default is `repeatable read`. - **Upserts** compile to `INSERT ... ON DUPLICATE KEY UPDATE`, or `INSERT IGNORE` when every non-conflict column is a conflict key. Neither takes a conflict target: the server picks the unique index. - **Arrays** are not native: a `string[]` field is stored as JSON and queried with the [JSON operators](https://uql-orm.dev/querying/json.md). - **JSON paths** differ: MySQL uses `->` / `->>`, MariaDB uses `JSON_VALUE()` / `JSON_EXTRACT()`, which every version it supports has. Emitting the right one is the main reason the entry points are separate. - **A JSON number** compares as a `DOUBLE` on both sides, fractions included. On MySQL that is also what lets a [`jsonPath` index](https://uql-orm.dev/entities/indexes.md#json-indexes) serve it; MariaDB has no expression indexes. - **`$elemMatch`** explodes the array with `JSON_TABLE`, one `JSON` column per element whose fields are read as paths. One value, or one of several, compiles to `JSON_CONTAINS()` or, on MySQL, `JSON_OVERLAPS()` instead, which a `jsonArray` index serves. On MySQL the exploded form carries a `NO_SEMIJOIN()` hint: without it the planner can answer every row with one row’s elements. - **Sorting by a JSON path** orders a number by its value: MySQL sorts the JSON value, MariaDB the number and then the text. - **Bound parameters** cap at 65,535, so large `insertMany` payloads are chunked for you. - **A populated to-many** is aggregated in the parent’s statement: an ordered `GROUP_CONCAT` on MySQL (8.0.14+), `JSON_ARRAYAGG` on MariaDB. The statement lifts `group_concat_max_len` for itself. ## Search `$text` compiles to `MATCH(...) AGAINST(...)`, which needs a `FULLTEXT` index over exactly the columns searched, in order: ```ts import { Index } from 'uql-orm'; @Index((post) => [post.title, post.body], { type: 'fulltext' }) ``` [Migrations](https://uql-orm.dev/migrations.md) create it, and follow one added to a table with rows by `OPTIMIZE TABLE`, without which InnoDB scores the index 0 or fails the search. Without it the server answers “Can’t find FULLTEXT index matching the column list”. A weighted index (`{ column: post.title, weight: 3 }`) also gets a `FULLTEXT` index over each column heavier than the lightest, since `MATCH` scores a column through an index of its own. See [column weights](https://uql-orm.dev/querying/full-text.md#column-weights). MariaDB 11.7+ has a native `VECTOR(n)` type and a `VECTOR INDEX` of its own, so [semantic search](https://uql-orm.dev/querying/semantic-search.md) works with no extension. A vector reads back from its packed bytes, every float32 digit kept. MySQL has the `VECTOR` column but no distance function outside HeatWave, so it has nothing to search on, and UQL stores a vector field there as JSON. Both drivers stream natively: `mysql2` through its result-set `.stream()`, `mariadb` through `queryStream`. # PGlite > Run UQL on PGlite, Postgres compiled to WASM and running in your own process. Source: https://uql-orm.dev/pglite [PGlite](https://pglite.dev) is Postgres itself compiled to WebAssembly. It runs inside your process, so there is no server to start, no container to wait on, and no port to pick. UQL treats it as the Postgres dialect it is: the same SQL, the same JSONB operators, the same `RETURNING`, the same pgvector. ```sh npm install uql-orm @electric-sql/pglite ``` ```ts import { PgliteQuerierPool } from 'uql-orm/pglite'; export const pool = new PgliteQuerierPool(); ``` That is an in-memory database, discarded when the process exits. Pass a directory to keep it: ```ts const pool = new PgliteQuerierPool('file://./pgdata'); ``` ## Where it earns its place **Tests.** A suite that runs against real Postgres no longer needs `docker compose up`, a CI service container, or a cleanup step. Point the pool at `memory://` and every test file gets a fresh Postgres in milliseconds. **Local development.** The dialect your migrations were generated for is the dialect you develop against, which SQLite-for-dev-Postgres-for-prod never gives you. ## Persisting to disk UQL drives transactions with plain `BEGIN` and `COMMIT` statements, which PGlite cannot see, so it flushes to the filesystem after every statement inside one. On a persistent `dataDir` that is worth turning off: ```ts const pool = new PgliteQuerierPool('file://./pgdata', { relaxedDurability: true, }); ``` The write still happens; PGlite just stops waiting on each flush before answering. ## Vector search pgvector is a separate WASM bundle, and PGlite needs it at construction time rather than through `CREATE EXTENSION` alone: ```sh npm install @electric-sql/pglite-pgvector ``` ```ts import { vector } from '@electric-sql/pglite-pgvector'; import { PgliteQuerierPool } from 'uql-orm/pglite'; const pool = new PgliteQuerierPool('memory://', { extensions: { vector } }); ``` From there [vector search](https://uql-orm.dev/ai-semantic-search.md) works exactly as on a server, `halfvec` and `sparsevec` included. Note that `@electric-sql/pglite-pgvector` pins an exact `@electric-sql/pglite` version, so the two are upgraded together. ## One connection, and what follows from it PGlite is single-connection by design. Queriers from a pool each get their own transaction *state*, but they share the one backend, so two transactions open at the same time are in fact the same transaction: ```ts const a = await pool.getQuerier(); const b = await pool.getQuerier(); await a.beginTransaction(); await b.beginTransaction(); // joins a's transaction rather than starting its own await b.rollbackTransaction(); // and discards a's writes with it ``` Give a unit of work that needs isolation its own `PgliteQuerierPool`, and therefore its own database. Sequential work through one pool is unaffected, which is what a test suite and a single-user dev session both are. Two smaller consequences: - `$lock` emits correct SQL (`SELECT ... FOR UPDATE`), but no second transaction can exist to contend with, so it cannot hand two workers different rows the way it does on a server. If that is the behaviour you are testing, test it on Postgres. - `findManyStream` streams for real: PGlite’s client has no cursor API, so the rows are paged through a server-side `DECLARE`/`FETCH` cursor. It runs in a transaction of its own where the caller has none, which on a single connection means nothing else runs against that database until the loop ends. ## Differences from `uql-orm/postgres` Everything above, plus one type detail: a `BYTEA` column reads back as a `Uint8Array` rather than a Node `Buffer`. A `Buffer` *is* a `Uint8Array`, so reading bytes is unchanged, but `instanceof Buffer` and Buffer-only methods are not available. Otherwise the two are interchangeable. Migrations generated against one apply to the other, and [`drift:check`](https://uql-orm.dev/migrations.md) works the same way, because both report themselves as the `postgres` dialect. # PostgreSQL > Run UQL on PostgreSQL with node-postgres: pooling, upserts, RLS, and cursor streaming. Source: https://uql-orm.dev/postgres Postgres is UQL’s most complete backend: native arrays, [JSON operators](https://uql-orm.dev/querying/json.md), [full-text](https://uql-orm.dev/querying/full-text.md) and pgvector [semantic search](https://uql-orm.dev/querying/semantic-search.md), cursor [streaming](https://uql-orm.dev/querying/streaming.md), and `RETURNING`, so writes hand back their generated ids without a second query. ```sh npm install uql-orm pg ``` Tests and local development have a second entry point onto this same dialect: [PGlite](https://uql-orm.dev/pglite.md) is Postgres compiled to WASM, so there is no server to start. ## Connect `PgQuerierPool` takes node-postgres’ `PoolConfig` verbatim: ```ts import { PgQuerierPool } from 'uql-orm/postgres'; export const pool = new PgQuerierPool({ connectionString: process.env.DATABASE_URL, max: 10, }); ``` The one default UQL changes is `keepAlive: true`, so a managed database’s idle connections are not silently dropped by a NAT. Where to build the pool, how to size `max` and when to `end()` it are the same on every driver: see [Pool](https://uql-orm.dev/pool.md). Behind PgBouncer or another transaction-mode pooler, session state does not survive between statements, so `SET LOCAL`, advisory locks and temp tables belong inside one [`transaction`](https://uql-orm.dev/querying/transactions.md) callback. ## Isolation levels All four work, passed straight through: ```ts await pool.transaction(run, { isolationLevel: 'serializable' }); ``` `serializable` and `repeatable read` can fail with a retryable serialization error (`40001`); retry the whole transaction, never a statement inside it. ## Upserts know what they did Postgres upserts compile to `INSERT ... ON CONFLICT DO UPDATE`, and UQL adds `(xmax = 0) AS "_created"` to the `RETURNING` clause, an MVCC trick that reports which branch ran: ```ts const { id, created } = await pool.upsertOne( User, { email: true }, { email, name }, ); // created: true when inserted, false when an existing row was updated ``` Only Postgres reports `created` per row. MySQL tells insert from update through its `affectedRows` convention, so it is reliable for a single-row upsert only, and the SQLite family, MariaDB and CockroachDB leave `created` as `undefined`. `upsertMany` reports no `created` at all: a batch’s row count is a weighted sum where it is reported, and a batch of mixed shapes is several statements. ## Row-level security UQL’s [`security` filters](https://uql-orm.dev/multi-tenancy.md) scope every query the ORM generates. Postgres RLS is the backstop under them, enforced even for raw SQL. Set the tenant inside the transaction that uses it, so it is discarded on commit: ```ts await pool.transaction(async (querier) => { await querier.run('SET LOCAL app.tenant_id = $1', [tenantId]); return querier.findMany(Invoice, { $limit: 50 }); }); ``` `SET LOCAL` is per-transaction, which is what pooled connections need: a plain `SET` would leak the tenant to whoever gets that connection next. ## Streaming Cursor streaming needs `pg-query-stream`, imported lazily on first use: ```sh npm install pg-query-stream ``` See [Streaming](https://uql-orm.dev/querying/streaming.md). ## Cloudflare Hyperdrive A Worker cannot hold a connection between requests, so every request would otherwise pay a full handshake across the internet. Hyperdrive terminates the Postgres protocol at the edge and keeps the warm pool to your database on its side, and `pg` runs there unchanged. It needs `nodejs_compat` and a compatibility date of `2024-09-23` or later: ```jsonc title="wrangler.jsonc" { "compatibility_date": "2026-08-01", "compatibility_flags": ["nodejs_compat"], "hyperdrive": [{ "binding": "HYPERDRIVE", "id": "" }], } ``` The binding only exists inside a request, so the pool is built there. Hyperdrive owns the real pooling, which is why the local `max` stays small: the Worker’s own concurrent-connection budget is the limit that matters. ```ts title="src/index.ts" import { PgQuerierPool } from 'uql-orm/postgres'; import { Item } from './models'; export default { async fetch(request: Request, env: Env, ctx: ExecutionContext) { const pool = new PgQuerierPool({ connectionString: env.HYPERDRIVE.connectionString, max: 5, }); try { const items = await pool.findMany(Item, { $limit: 20 }); return Response.json(items); } finally { ctx.waitUntil(pool.end()); } }, }; ``` [Migrations](https://uql-orm.dev/migrations.md) run from CI against the database’s own hostname, not through the binding. ## Elsewhere - Under Bun, [`bun:sql`](https://uql-orm.dev/bun-sql.md) speaks the Postgres protocol natively, so the `pg` dependency goes away. - [CockroachDB](https://uql-orm.dev/cockroachdb.md) shares this wire protocol with a few deliberate differences. - [Supabase](https://uql-orm.dev/supabase.md) is Postgres with its own pooler endpoints and RLS conventions. - In a function that freezes between invocations, pool placement changes: see [Serverless](https://uql-orm.dev/serverless.md). # SQLite > Run UQL on SQLite with Node's built-in driver, better-sqlite3, or bun:sqlite. Source: https://uql-orm.dev/sqlite UQL speaks SQLite through three drivers. All of them produce identical SQL and identical results; they differ only in what you have to install and how fast they read. | Pool | Driver | Install | | - | - | - | | `NodeSqliteQuerierPool` | Node’s built-in `node:sqlite` | nothing | | `Sqlite3QuerierPool` | `better-sqlite3`, or `bun:sqlite` under Bun | `npm i better-sqlite3` (Bun needs nothing) | | `TursoQuerierPool` / `LibsqlQuerierPool` | libSQL / Turso over the wire | see [Turso](https://uql-orm.dev/turso.md) | If SQLite here is standing in for a Postgres you run in production, [PGlite](https://uql-orm.dev/pglite.md) needs no server either and is actually Postgres. ## No dependency at all `NodeSqliteQuerierPool` uses the SQLite that ships inside Node, so there is no native module to build and nothing to install: ```ts import { NodeSqliteQuerierPool } from 'uql-orm/sqlite'; export const pool = new NodeSqliteQuerierPool('app.db'); ``` That matters most where a native build is awkward: slim container images, CI without a toolchain, and anywhere `node-gyp` is unwelcome. UQL requires Node 24, which is well past the 22.13 where `node:sqlite` became usable, so there is no version to check. Loadable extensions work too, which is what vector search needs, since SQLite ships no vector functions of its own: ```ts import { getLoadablePath } from 'sqlite-vec'; const pool = new NodeSqliteQuerierPool('app.db', { extensions: [getLoadablePath()], }); ``` ## Full-text search `$text` matches an [FTS5](https://sqlite.org/fts5.html) virtual table, which migrations do not (automatically) create: make it yourself and declare an entity over it, then `$text` and `$sort: { $text }` (ranked by `BM25`) work as on any engine. The same goes for libSQL, Turso and D1, which all have FTS5. See [Full-text search](https://uql-orm.dev/querying/full-text.md). ## When to prefer `better-sqlite3` `better-sqlite3` is faster on reads. Measured on 20k in-memory rows, `node:sqlite` was about 20% quicker on inserts but 1.3x slower on point reads and 1.4x slower on 100-row reads. Reads dominate most workloads, so `better-sqlite3` stays the recommendation when throughput matters and installing a native module is not a problem. `node:sqlite` is also still a release candidate in Node’s own stability index, while `better-sqlite3` is long settled. ```ts import { Sqlite3QuerierPool } from 'uql-orm/sqlite'; export const pool = new Sqlite3QuerierPool('app.db'); ``` Under Bun this same pool uses `bun:sqlite` automatically, so Bun projects install nothing either. ## Populated relations A populated to-many is aggregated with `json_group_array`, whose `ORDER BY` needs SQLite 3.44. Turso lacks it, so its rows keep the subquery’s order. A populated `float` keeps 15 significant digits on SQLite 3.51 and libSQL, 17 on 3.53. # Supabase > Run UQL on Supabase Postgres, pick the right pooler endpoint, and layer UQL security filters over Postgres RLS. Source: https://uql-orm.dev/supabase Supabase is Postgres, so everything on the [PostgreSQL](https://uql-orm.dev/postgres.md) page applies unchanged: same entities, queries, migrations and `pg` driver. What is Supabase-specific is which endpoint you connect to and how UQL’s tenant scoping lines up with RLS. ```sh npm install uql-orm pg ``` ## Which connection string Postgres holds session state (prepared statements, `SET`, advisory locks, temp tables) on a connection, and a transaction-mode pooler hands you a different backend connection between statements. So the endpoint is a real choice: | Endpoint | Port | Use it for | | - | - | - | | `db..supabase.co` | 5432 | Migrations, `pg_dump`, long-lived servers on an IPv6-capable network. | | `aws-.pooler.supabase.com` (session mode) | 5432 | The same, from an IPv4-only network. | | `aws-.pooler.supabase.com` (transaction mode) | 6543 | Serverless and anything opening many short-lived connections. | The direct hostname resolves to IPv6 only unless you buy the IPv4 add-on, which is the usual cause of `ENETUNREACH` from a CI runner or a container with no IPv6 route. ```ts import { PgQuerierPool } from 'uql-orm/postgres'; export const pool = new PgQuerierPool({ connectionString: process.env.DATABASE_URL, max: 10, }); ``` Transaction mode rejects prepared statements, since the statement would be prepared on one backend connection and executed on another. UQL issues none of its own, so the common paths work; if you enable them in `pg`, turn them off for that endpoint. That is also why [migrations](https://uql-orm.dev/migrations.md) belong on the direct endpoint. Point the migrator at it explicitly: ```ts title="uql.config.ts" import type { Config } from 'uql-orm'; import { PgQuerierPool } from 'uql-orm/postgres'; import { Company, Invoice } from './entities.js'; export default { pool: new PgQuerierPool({ connectionString: process.env.DIRECT_DATABASE_URL, }), entities: [Company, Invoice], migrationsPath: './migrations', } satisfies Config; ``` ## Row-level security Your RLS policies are written against `auth.uid()` and `auth.jwt()`, which the PostgREST layer populates per request. Connecting with `pg` bypasses that layer: you are the `postgres` role, policies do not apply, and nothing scopes your queries. There are two ways to get scoping back, and they stack. A **UQL security filter** is the boundary in application code. It is AND-merged into every query the ORM generates, cannot be turned off from the wire, and fails closed when the context is missing: ```ts import { Entity, Filter } from 'uql-orm'; @Filter('tenant', { where: (ctx) => ctx?.orgId != null ? { organizationId: ctx.orgId } : undefined, security: true, }) @Entity() export class Invoice {} ``` Set the context once per request and every read, write, relation and cascade is scoped. See [Multi-tenancy](https://uql-orm.dev/multi-tenancy.md). **Postgres RLS** is the backstop underneath, and it covers raw SQL and anything else that skips the ORM. Connect as a role the policies apply to, and set the claim inside the transaction that reads it: ```ts await pool.transaction(async (querier) => { await querier.run("SELECT set_config('request.jwt.claims', $1, true)", [ JSON.stringify(claims), ]); return querier.findMany(Invoice, { $limit: 50 }); }); ``` The third argument is what scopes the setting to the transaction. Drop it and the claim stays on the connection, so the next request to borrow it inherits the previous caller’s identity. ## What you keep UQL replaces `supabase-js` for data access only. Auth, Storage, Realtime and Edge Functions talk to their own endpoints and keep working; a common shape is Supabase Auth issuing the JWT, your server verifying it, and the verified claims becoming the UQL context above. pgvector is installed on every project, so [semantic search](https://uql-orm.dev/querying/semantic-search.md) needs nothing beyond the [index](https://uql-orm.dev/entities/indexes.md) your migration creates. Edge Functions run on Deno with no TCP, so `pg` cannot connect there. For pool placement in functions that freeze, see [Serverless](https://uql-orm.dev/serverless.md). # Turso & LibSQL > Run UQL on Turso Cloud from edge runtimes, on the embedded Turso engine, or on libSQL. Source: https://uql-orm.dev/turso All three options are SQLite underneath, so entities, queries and [migrations](https://uql-orm.dev/migrations.md) are identical across them. Only the pool changes. | Package | Entry point | Use it for | | - | - | - | | `@tursodatabase/serverless` | `uql-orm/turso` | Turso Cloud over pure `fetch()`. No native dependency, so it runs on Cloudflare Workers and Vercel Edge. | | `@tursodatabase/database` | `uql-orm/turso/local` | The embedded Rust engine, for local-first and desktop apps. | | `@libsql/client` | `uql-orm/libsql` | Existing libSQL/sqld databases, including embedded replicas and clients built for the edge. | Turso Database is the ground-up Rust rewrite of SQLite; libSQL is the earlier fork of SQLite’s C source, still maintained. A Turso Cloud database runs libSQL unless it was created as `tursodb`, which runs the Rust engine. `uql-orm/turso` reaches either, so it emits only SQL both accept. The Rust engine cannot read the table a write changes from inside a subquery, so an `updateMany` or `deleteMany` filtered by a relation or a [relation aggregate](https://uql-orm.dev/entities/computed-fields.md#relation-aggregates) reads the ids of the rows it names first, on both drivers. ## Turso Cloud ```sh npm install uql-orm @tursodatabase/serverless ``` ```ts import { TursoQuerierPool } from 'uql-orm/turso'; const pool = new TursoQuerierPool({ url: process.env.TURSO_DATABASE_URL!, authToken: process.env.TURSO_AUTH_TOKEN, }); const todos = await pool.findMany(Todo, { $select: { id: true, title: true }, $limit: 50, }); ``` The driver is loaded on first use rather than in the constructor, so a pool can sit at module scope in a Worker without loading it until a request needs it. Nothing extra is required in an edge runtime: the driver speaks HTTP through `fetch()` and pulls in no native binary. Import only `uql-orm/turso` in an edge bundle; `uql-orm/turso/local` is a separate entry point precisely because it reaches for binaries that do not resolve on Workers. Every querier opens a session of its own, one stream on the server, so queriers never wait on each other and a transaction is plain `BEGIN`/`COMMIT` on its stream. [Streaming](https://uql-orm.dev/querying/streaming.md) reads the rows off the statement’s cursor as the server steps it. The settings are the driver’s own, so `requestHeaders` (for routing through a gateway) and `defaultQueryTimeout` apply too. ## Embedded Turso ```sh npm install uql-orm @tursodatabase/database ``` ```ts import { TursoLocalQuerierPool } from 'uql-orm/turso/local'; const pool = new TursoLocalQuerierPool('app.db'); ``` Pass `':memory:'` for an ephemeral database; the second argument takes the engine’s own options (`readonly`, `timeout`, `encryption`, `experimental` and the rest). This driver supports [streaming](https://uql-orm.dev/querying/streaming.md) natively rather than buffering the full result set. ## libSQL ```sh npm install uql-orm @libsql/client ``` ```ts import { LibsqlQuerierPool } from 'uql-orm/libsql'; const pool = new LibsqlQuerierPool({ url: process.env.LIBSQL_URL!, authToken: process.env.LIBSQL_AUTH_TOKEN, }); ``` For an embedded replica (a local file synced from a remote), migrations must run against the remote so DDL is not lost on the next sync. Give UQL both URLs and the migrator opens its own connection to `syncUrl` for schema changes: ```ts const pool = new LibsqlQuerierPool({ url: 'file:./local.db', syncUrl: process.env.LIBSQL_SYNC_URL, authToken: process.env.LIBSQL_AUTH_TOKEN, }); ``` The pool also takes a client you built, such as `@libsql/client/web` for an edge runtime or `@libsql/client-wasm`, and shares it with every querier. It is yours, so `pool.end()` leaves it open: ```ts import { createClient } from '@libsql/client/web'; import { LibsqlQuerierPool } from 'uql-orm/libsql'; const pool = new LibsqlQuerierPool( createClient({ url: process.env.TURSO_DATABASE_URL!, authToken: process.env.TURSO_AUTH_TOKEN, }), ); ``` ## Vector search Built in on both, so [semantic search](https://uql-orm.dev/querying/semantic-search.md) needs no extension: `cosine` and `l2` everywhere, plus `inner` on the embedded engine. A Turso Cloud database may run libSQL, which has no dot product, so `uql-orm/turso` refuses `inner` while building the query rather than sending a call the server lacks. On libSQL, a vector `@Index` is a DiskANN index that ranked, paged searches read. The Rust engine has none: a `tursodb` database refuses the index, and the embedded engine builds it plain and scans. See [vector indexes](https://uql-orm.dev/querying/semantic-search.md#vector-indexes). The distance functions are named differently on each engine (`vector_distance_cos` here, `vec_distance_cosine` on SQLite with sqlite-vec), and UQL emits the right one per dialect. Ask for a metric an engine lacks and it throws while building the query rather than sending a call to a function that is not there. `CREATE INDEX` has no `USING` clause anywhere in the SQLite family, so an index declaring a `type` (`hnsw` on an entity written for Postgres, or a plain `btree`) would be a syntax error. UQL drops the clause and emits a plain index, so the entity migrates unchanged. ## What the ORM costs on top of the driver The embedded engine runs in-process, which makes it the honest place to measure what UQL adds over calling `@tursodatabase/database` directly. Prepared statements, same SQL, same database: | Operation | Raw driver | Through UQL | Difference | | - | - | - | - | | Read by primary key | 3.4 us | 19.4 us | +16 us | | Insert 10 rows | 128 us | 157 us | +29 us | | Filtered top-10 over \~11k rows | 2.91 ms | 2.99 ms | +0.08 ms | Median of 5 runs of 200 iterations after 30 warmup iterations, in-memory database seeded with 1,000 rows, Node 24 on an M4 Pro. The driver hands back raw row arrays and UQL hands back hydrated entities, so the difference covers building the SQL, binding, and mapping rows to objects. Read it in absolute terms. The ORM costs tens of microseconds per operation, whatever the query. The percentage is a property of the query, not of UQL: that same 16 us is 460% of a 3 us primary-key read and 3% of a 3 ms scan. On Turso Cloud every statement is an HTTP round trip, so it disappears. # HTTP (any framework) > Serve UQL entities over HTTP from any framework with the framework-agnostic transport core. Source: https://uql-orm.dev/http `uql-orm/http` turns your entities into a REST API without tying you to a web framework. It owns the route table, the request/response envelopes, query (de)serialization, querier lifecycle, transactions, and authorization hooks. Adapters are thin bindings on top: - `createFetchHandler` returns a web-standard `(request: Request) => Promise`. - [`uql-orm/express`](https://uql-orm.dev/express.md) binds the same core to Express 5. - `createRequestHandler` takes a normalized request object, for frameworks that are neither (see [Fastify](https://uql-orm.dev/fastify.md)). This whole layer is optional; UQL works as a standalone ORM without it. The query you serve here is the one you write on the server and send from the browser: [one query, every transport](https://uql-orm.dev/querying/querier.md#the-same-query-every-transport). ## Mounting ```ts import { createFetchHandler } from 'uql-orm/http'; import { pool } from './uql.config.js'; import { Post, User } from './shared/models/index.js'; const handler = createFetchHandler({ pool, include: [User, Post] }); ``` | Runtime | Mount | `basePath` | | - | - | - | | [Hono](https://uql-orm.dev/hono.md), [Elysia](https://uql-orm.dev/elysia.md) | `app.mount('/api', handler)` | no, `mount` strips the prefix | | `Bun.serve` | `{ fetch: handler }`, or `{ routes: { '/api/*': handler } }` under a prefix | only for the wildcard form | | `Deno.serve`, Cloudflare Workers | `Deno.serve(handler)` / `export default { fetch: handler }` | no, it serves the root | | [Next.js](https://uql-orm.dev/nextjs.md), [Astro](https://uql-orm.dev/astro.md), [React Router](https://uql-orm.dev/react-router.md), [TanStack Start](https://uql-orm.dev/tanstack-start.md) | one catch-all route, per recipe | yes | | Nitro / h3 v1 | `fromWebHandler(handler)` in a catch-all | yes | Where the table says `basePath` is required, pass the prefix you mounted at: `createFetchHandler({ pool, include: [User, Post], basePath: '/api/uql' })`. File-based routers match a prefix without rewriting the URL, so the handler has to be told to ignore it. On h3 v2 the bridge becomes `defineEventHandler((event) => handler(event.req))`, since `event.req` is a `Request` there. ## The pool The handler runs on the [pool](https://uql-orm.dev/pool.md) you give it. One module usually builds it, registers the entities, and is what [`uql-migrate`](https://uql-orm.dev/migrations.md) reads: ```ts title="uql.config.ts" import type { Config } from 'uql-orm'; import { PgQuerierPool } from 'uql-orm/postgres'; import { Post, User } from './shared/models/index.js'; export const pool = new PgQuerierPool({ connectionString: process.env.DATABASE_URL, }); export default { pool, entities: [User, Post] } satisfies Config; ``` Pass a function to pick one per request, which is how a single deployment serves a database per tenant. It runs after `getContext`, with the adapter’s request and the context that resolved from it, and only for requests that reach a statement: a `pre` hook that throws never triggers the lookup. ```ts const handler = createFetchHandler({ include: [User, Post], getContext: (request) => ({ tenantId: tenantOf(request) }), pool: (_request, { tenantId }) => poolFor(tenantId as string), }); ``` ## Wire protocol For an entity named `User` (paths derive from the kebab-cased class name): | Operation | Method | Endpoint | Body | Description | | - | - | - | - | - | | `findMany` | `GET` | `/user` | | List records; add `?count=true` for the total count. | | `findOne` | `GET` | `/user/one` | | First record matching the query. | | `count` | `GET` | `/user/count` | | Count matching records. | | `findOneById` | `GET` | `/user/:id` | | One record by primary key. | | `insertOne` | `POST` | `/user` | object | Insert a record. | | `insertMany` | `POST` | `/user/many` | array | Insert many records. | | `saveOne` | `PUT` | `/user` | object | Insert or update (upsert). | | `saveMany` | `PUT` | `/user/many` | array | Insert or update many. | | `updateMany` | `PATCH` | `/user` | object | Bulk partial update of records matching `$where`. | | `updateOneById` | `PATCH` | `/user/:id` | object | Partial update by primary key. | | `deleteOneById` | `DELETE` | `/user/:id` | | Delete by primary key. | | `deleteMany` | `DELETE` | `/user` | | Bulk delete of records matching the query. | Delete routes [soft-delete](https://uql-orm.dev/entities/soft-delete.md) by default where the entity has the field; `?hardDelete=true` overrides. `GET` endpoints take the [serializable query](https://uql-orm.dev/querying/querier.md) as JSON strings in the query string (`$skip` and `$limit` as numbers). Writes run in a transaction, reads acquire and release a querier, `HEAD` mirrors `GET`, and malformed JSON is a `400`, as is a `$where` that is not an object (`$where=[1,2]`; name the key instead, `{"id":[1,2]}`). Responses use one envelope everywhere: ```jsonc // success { "data": ..., "count": 3 } // error (status mirrors `code`) { "error": { "message": "forbidden", "code": 403 } } ``` A database constraint failure gets its own status and a generic message, never the driver’s: `409` `Conflict` for a duplicate or a missing referenced row, `400` `Bad Request` for a not-null or check violation ([error kinds](https://uql-orm.dev/querying/errors.md)). An update against a stale [version](https://uql-orm.dev/entities/optimistic-locking.md) is a `409` too, with a message naming the entity and both versions; a payload carrying no version at all, or a `save` or `upsert` of a versioned entity, is a `400`. Everything else is a `500`. The route table is exported as `CRUD_ROUTES`, its keys compile-time constrained to `UniversalQuerier` method names, so the adapters, the [browser client](https://uql-orm.dev/browser.md), and your own tooling share one source of truth. ## HTTP QUERY (RFC 10008) `QUERY` is an alternate transport for the three read routes (`/user`, `/user/one`, `/user/count`): same semantics as `GET`, but the query travels in the body, so large `$where`/`$populate` never hit URL-length limits. The core, the Express adapter, Node and Bun all support it. The host framework has to route it too: mounts and wildcards that forward the raw request do (Hono, Elysia, `Bun.serve`, an Astro `ALL` export or [`src/fetch.ts`](https://uql-orm.dev/astro.md#auto-generated-crud)), routers keyed to named verbs do not (Next.js route handlers, React Router’s loader/action split, `fastify.all`). It stays opt-in in the [browser client](https://uql-orm.dev/browser.md) because a cross-origin `QUERY` needs a CORS preflight and some proxies still drop unknown methods. ## Authorization hooks Hooks run before the querier is touched, can be async, receive the adapter’s native request as `context`, and abort by throwing (a numeric `status` becomes the HTTP status): ```ts const handler = createFetchHandler({ pool, include: [User], async pre({ context }) { if (!(await authenticate(context.headers.get('authorization')))) { throw Object.assign(new Error('unauthorized'), { status: 401 }); } }, preSave(ctx) { ctx.body = { ...(ctx.body as object), updatedAt: Date.now() }; }, }); ``` | Hook | Lifecycle | Use case | | - | - | - | | `pre` | Before every operation. | Logging, auditing, global validation. | | `preSave` | Before `POST`, `PUT`, `PATCH`. | Injecting `creatorId`, sanitization. | | `preFilter` | Before `GET`, `DELETE`. | Query shaping, forcing soft-delete. Not for tenant isolation. | | `post` | After the operation (post-commit). | Response shaping: strip secrets, derive presentation fields. | The hook context also carries `meta`, `op` and `method`, so one hook can branch per entity or operation. `post` receives the mutable success envelope, which covers sanitization a forced `$select`/`$exclude` cannot express: ```ts const handler = createFetchHandler({ pool, include: [User], post({ meta }, envelope) { if (meta.entity === User) { envelope.data = (envelope.data as User[]).map( ({ password, ...rest }) => ({ ...rest, hasPassword: !!password, }), ); } }, }); ``` ## Tenant scoping Folding a tenant id into `$where` from `preFilter` is not isolation: it is not AND-merged, does not reach joined relations, and does not fail closed. Pass `getContext` instead and declare a `security` [filter](https://uql-orm.dev/querying/filters.md). `getContext` runs the whole request inside `withContext`, so every query it makes is scoped and a client cannot opt out of it: ```ts const handler = createFetchHandler({ pool, include: [Invoice], getContext: (req) => ({ tenantId: authenticate(req).tenantId }), // verified session / JWT }); ``` ```ts import { Entity, Filter } from 'uql-orm'; @Filter('tenant', { where: (ctx) => ctx?.tenantId != null ? { companyId: ctx.tenantId } : undefined, security: true, }) @Entity() export class Invoice {} ``` See [Multi-tenancy](https://uql-orm.dev/multi-tenancy.md). ## Composing with custom routes The handlers cover single-entity CRUD only: anything else 404s from `createFetchHandler` and falls through via `next()` in the Express adapter, so both styles share one prefix. Read-modify-write logic, multi-entity transactions, aggregations, raw SQL, file uploads, streaming and third-party side effects stay in routes you write. > **Minification** > > Entity routes derive from `entity.name` at runtime. If you minify your server bundle, keep class names (`keep_classnames` in terser, `keepNames` in esbuild), or routes and client URLs change. # Express > Query UQL from your own Express routes, and optionally auto-generate REST endpoints with the querier middleware. Source: https://uql-orm.dev/express `pool` is a plain ORM, so an Express app needs nothing else from UQL: your own routes query it directly. The [middleware](#auto-generated-entity-routes) below that is optional, a thin adapter over the [HTTP transport core](https://uql-orm.dev/http.md) for when you want CRUD across many entities without writing it. ## Your own routes ```ts import express from 'express'; import { pool } from './uql.config.js'; import { Post } from './shared/models/index.js'; const app = express(); app.get('/posts', async (_req, res) => { const posts = await pool.findMany(Post, { $where: { published: true }, $limit: 20, }); res.json(posts); }); app.listen(3000); ``` Work spanning several statements goes in `pool.transaction`, which hands you the [querier](https://uql-orm.dev/querying/querier.md) to run all of them on. ## Auto-generated entity routes Requires Express 5, whose route syntax the middleware is written against. NestJS has been on Express 5 since v11, so a current [Nest app](https://uql-orm.dev/nestjs.md) already qualifies. ```ts import { querierMiddleware } from 'uql-orm/express'; import { User } from './shared/models/index.js'; app.use(express.json()); // the write routes and the QUERY transport need a parsed body app.use('/api', querierMiddleware({ pool, include: [User, Post] })); ``` That mounts the full [wire protocol](https://uql-orm.dev/http.md#wire-protocol) per entity, including the [`QUERY` transport](https://uql-orm.dev/http.md#http-query-rfc-10008). Unknown entities and routes fall through via `next()`, so your own routes (webhooks, payments, SSE) share the prefix. `:id` is not hardcoded: the route parameter maps to whatever property carries `@Id()`, so `uuid` or `itemNo` work unchanged, the latter [naming its key](https://uql-orm.dev/entities/basic.md#naming-the-key), as any unconventional one does. ## Hooks The [core’s hooks](https://uql-orm.dev/http.md#authorization-hooks) apply, with `ctx.context` bound to the `express.Request`: ```ts app.use( '/api', querierMiddleware({ pool, include: [User, Post], async pre({ context }) { if (!context.user) { throw Object.assign(new Error('unauthorized'), { status: 401 }); // numeric status becomes the HTTP status } }, preSave(ctx) { ctx.body = { ...(ctx.body as object), creatorId: ctx.context.user?.id }; }, }), ); ``` For tenant isolation pass `getContext` plus a `security` [filter](https://uql-orm.dev/querying/filters.md) rather than folding `$where` in `preFilter`: it scopes every query in the request, cannot be bypassed from the wire, and fails closed. See [Multi-tenancy](https://uql-orm.dev/multi-tenancy.md). ## Error handling Errors go to `next(err)`, so your own error middleware keeps working. The exported `errorHandler` renders the canonical [envelope](https://uql-orm.dev/http.md#wire-protocol) and honors a numeric `status` thrown by a hook: ```ts import { errorHandler } from 'uql-orm/express'; app.use('/api', querierMiddleware({ pool, include: [User] })); app.use(errorHandler); ``` # Fastify > Query UQL from your own Fastify routes, and optionally serve entity CRUD by bridging the framework-agnostic request handler. Source: https://uql-orm.dev/fastify `pool` is a plain ORM, so a Fastify app needs nothing else from UQL: your own routes query it directly. ```ts import Fastify from 'fastify'; import { pool } from './uql.config.js'; import { Post } from './shared/models/index.js'; const fastify = Fastify(); fastify.get('/posts', () => pool.findMany(Post, { $where: { published: true }, $limit: 20 }), ); await fastify.listen({ port: 3000 }); ``` Work spanning several statements goes in `pool.transaction`, which hands you the [querier](https://uql-orm.dev/querying/querier.md) to run all of them on. ## Auto-generated entity routes Optional, and for when CRUD across many entities is not worth writing by hand. Fastify is not fetch-native, so it binds the [HTTP transport core](https://uql-orm.dev/http.md) through `createRequestHandler`, which takes a normalized request object and returns `{ status, body }`. The bridge is one catch-all route: ```ts import { createRequestHandler, toErrorResponse } from 'uql-orm/http'; import { User } from './shared/models/index.js'; const handle = createRequestHandler({ pool, include: [User, Post] }); fastify.all<{ Params: { entityPath: string; subPath?: string }; Querystring: Record; }>('/api/:entityPath/:subPath?', async (req, reply) => { const { entityPath, subPath } = req.params; const pending = handle({ method: req.method, entityPath, subPath, query: req.query, body: req.body, context: req, // passed through to hooks }); // unknown entity/route: hand it to Fastify's not-found handler if (!pending) return reply.callNotFound(); try { const { status, body } = await pending; return reply.status(status).send(body); } catch (err) { const { status, body } = toErrorResponse(err); return reply.status(status).send(body); } }); ``` That serves the full [wire protocol](https://uql-orm.dev/http.md#wire-protocol) per entity. Thrown hook errors map to the canonical [envelope](https://uql-orm.dev/http.md#wire-protocol) via `toErrorResponse`, with a numeric `status` on the error becoming the HTTP status. Fastify already parses `application/json`, so the write routes need nothing added. Register your own routes on their own paths and Fastify matches them before this catch-all, so both styles coexist. `fastify.all` registers the standard verbs only, so this bridge serves the `GET` read transport but not the [`QUERY` method](https://uql-orm.dev/http.md#http-query-rfc-10008). ## Hooks `createRequestHandler` accepts the core’s [hooks](https://uql-orm.dev/http.md#authorization-hooks); the hook `context` is whatever you passed above, here the Fastify request, typed through the handler’s type parameter with the `user` your auth plugin sets: ```ts import type { FastifyRequest } from 'fastify'; type AuthedRequest = FastifyRequest & { user?: { id: string } }; const handle = createRequestHandler({ pool, include: [User, Post], async pre({ context }) { if (!context.user) { throw Object.assign(new Error('unauthorized'), { status: 401 }); } }, }); ``` For tenant isolation pass `getContext` plus a `security` [filter](https://uql-orm.dev/querying/filters.md) rather than folding `$where` by hand: it scopes every query in the request, cannot be bypassed from the wire, and fails closed. See [Multi-tenancy](https://uql-orm.dev/multi-tenancy.md). # Hono > Query UQL from your own Hono routes, and optionally mount entity CRUD with the fetch-native transport core. Source: https://uql-orm.dev/hono `pool` is a plain ORM, so a Hono app needs nothing else from UQL: your own routes query it directly. ```ts import { Hono } from 'hono'; import { pool } from './uql.config.js'; import { Post } from './shared/models/index.js'; const app = new Hono(); app.get('/posts', async (c) => { const posts = await pool.findMany(Post, { $where: { published: true }, $limit: 20, }); return c.json(posts); }); export default app; // Bun and Workers; Deno.serve(app.fetch), or serve(app) from @hono/node-server ``` Work spanning several statements goes in `pool.transaction`, which hands you the [querier](https://uql-orm.dev/querying/querier.md) to run all of them on. ## Auto-generated entity routes Optional, and for when CRUD across many entities is not worth writing by hand. Hono is fetch-native, so it mounts the [HTTP transport core](https://uql-orm.dev/http.md) directly: `.mount()` strips the prefix before the handler sees the request. Nothing to install beyond `uql-orm`. ```ts import { cors } from 'hono/cors'; import { createFetchHandler } from 'uql-orm/http'; import { User } from './shared/models/index.js'; const handler = createFetchHandler({ pool, include: [User, Post] }); app.use('*', cors()); app.get('/health', (c) => c.text('ok')); app.post('/checkout', async (c) => c.json(await runCheckout(c.req.raw))); // custom business logic app.mount('/api', handler); // entity CRUD under /api ``` That serves the full [wire protocol](https://uql-orm.dev/http.md#wire-protocol) per entity, [`QUERY`](https://uql-orm.dev/http.md#http-query-rfc-10008) included, since `.mount()` forwards every method. It claims only the `/api` prefix, so your own routes and middleware sit beside the generated CRUD; keep them for read-modify-write logic, multi-entity transactions, aggregations, uploads and streaming. Unknown routes under the prefix 404 from the handler rather than falling through. ## Authorization and tenant scoping Nothing here is Hono-specific, so the core documents it once: [`getContext`](https://uql-orm.dev/http.md#tenant-scoping) takes the web `Request` and scopes every query in it through a `security` [filter](https://uql-orm.dev/querying/filters.md), and the [hooks](https://uql-orm.dev/http.md#authorization-hooks) shape a response, stamp a field or reject a payload. A hook that throws with a numeric `status` becomes that HTTP status. ## Runtimes The same handler runs unchanged on Bun, Deno and Node. [D1](https://uql-orm.dev/cloudflare-d1.md) is the exception: its binding arrives with the request, and `.mount()` hands the handler a bare `Request` with no way back to `c.env`. Route it yourself and pass the prefix as `basePath`: ```ts import { Hono } from 'hono'; import { D1QuerierPool } from 'uql-orm/d1'; import { createFetchHandler } from 'uql-orm/http'; import { Post, User } from './shared/models/index.js'; const app = new Hono<{ Bindings: { DB: D1Database } }>(); app.all('/api/*', (c) => { const handler = createFetchHandler({ pool: new D1QuerierPool(c.env.DB), include: [User, Post], basePath: '/api', }); return handler(c.req.raw); }); ``` D1 has no transactions, so its write routes cannot run; reads and single-statement writes do. Hono’s own RPC client infers from chained routes, so `hc` covers the routes you write but cannot see mounted CRUD: the typed client for that is UQL’s [browser client](https://uql-orm.dev/browser.md), which sends the same query you write on the server. For per-procedure contracts, see [tRPC](https://uql-orm.dev/trpc.md) and [oRPC](https://uql-orm.dev/orpc.md). # Elysia > Query UQL from your own Elysia routes, and optionally mount entity CRUD with the fetch-native transport core. Source: https://uql-orm.dev/elysia `pool` is a plain ORM, so an Elysia app needs nothing else from UQL: your own routes query it directly. ```ts import { Elysia } from 'elysia'; import { pool } from './uql.config.js'; import { Post } from './shared/models/index.js'; new Elysia() .get('/posts', () => pool.findMany(Post, { $where: { published: true }, $limit: 20 }), ) .listen(3000); // Bun; on Node, `new Elysia({ adapter: node() })` from @elysiajs/node ``` Work spanning several statements goes in `pool.transaction`, which hands you the [querier](https://uql-orm.dev/querying/querier.md) to run all of them on. ## Auto-generated entity routes Optional, and for when CRUD across many entities is not worth writing by hand. Elysia is fetch-native, so it mounts the [HTTP transport core](https://uql-orm.dev/http.md) directly: `.mount()` strips the prefix before the handler sees the request. Nothing to install beyond `uql-orm`. ```ts import { cors } from '@elysiajs/cors'; import { Elysia } from 'elysia'; import { createFetchHandler } from 'uql-orm/http'; import { pool } from './uql.config.js'; import { Post, User } from './shared/models/index.js'; const handler = createFetchHandler({ pool, include: [User, Post] }); new Elysia() .use(cors()) .get('/health', () => 'ok') .post('/checkout', ({ body }) => runCheckout(body)) // custom business logic .mount('/api', handler) // entity CRUD under /api .listen(3000); ``` That serves the full [wire protocol](https://uql-orm.dev/http.md#wire-protocol) per entity, [`QUERY`](https://uql-orm.dev/http.md#http-query-rfc-10008) included, since `.mount()` routes every method and forwards the body unparsed. It claims only the `/api` prefix, so your own routes, plugins and lifecycle hooks sit beside the generated CRUD; keep them for read-modify-write logic, multi-entity transactions, aggregations, uploads and streaming. Unknown routes under the prefix 404 from the handler rather than falling through. ## Authorization and tenant scoping Nothing here is Elysia-specific, so the core documents it once: [`getContext`](https://uql-orm.dev/http.md#tenant-scoping) takes the web `Request` and scopes every query in it through a `security` [filter](https://uql-orm.dev/querying/filters.md), and the [hooks](https://uql-orm.dev/http.md#authorization-hooks) shape a response, stamp a field or reject a payload. A hook that throws with a numeric `status` becomes that HTTP status. `.mount()` is opaque to Elysia’s type system, so Eden Treaty sees nothing under `/api`: the typed client for that is UQL’s [browser client](https://uql-orm.dev/browser.md), which sends the same query you write on the server. Eden keeps covering your hand-written routes. For per-procedure contracts, see [tRPC](https://uql-orm.dev/trpc.md) and [oRPC](https://uql-orm.dev/orpc.md). # NestJS > Use UQL in NestJS with the UqlModule, injectable querier pool, and auto-generated entity routes. Source: https://uql-orm.dev/nestjs `uql-orm/nestjs` registers your querier pool with Nest’s DI container and ends it on application shutdown. Nest 10+, tested against 12. ## Before you start **CommonJS is fine, ESM is fine.** UQL ships ESM only, and so do Nest 12’s own packages: a CommonJS app loads both through Node’s `require(esm)`, unflagged since Node 20.19, and UQL asks for Node 24 anyway. `nest new` prompts for CJS or ESM and `nest upgrade` keeps whichever you have, so neither needs changing. The one graph `require(esm)` refuses is one with top-level await, and UQL has none. On CommonJS, `module` has to be `commonjs`, which Nest scaffolds, or `nodenext`: `node16` and `node18` refuse the import though Node loads it. **Declare entities with `defineEntity`.** Nest injects constructor parameters with a parameter decorator, which exists only in the legacy spec, so a Nest project keeps `experimentalDecorators: true`, and one `tsconfig.json` cannot mix specs with UQL’s standard decorators. The [imperative API](https://uql-orm.dev/entities/imperative.md) is the way round it: same options, identical metadata, the same checks bar one foreign-key case, and nothing else on this page changes. Not a temporary gap: TC39’s parameter decorators are a separate Stage 1 proposal, untouched by Nest 12. ## Setup ```ts title="app.module.ts" import { Module } from '@nestjs/common'; import { UqlModule } from 'uql-orm/nestjs'; import { pool } from './uql.config.js'; @Module({ imports: [UqlModule.forRoot({ pool })], // global by default; pass global: false to scope it }) export class AppModule {} ``` `forRootAsync` builds the pool from other providers, `ConfigService` from `@nestjs/config` being the usual one: ```ts import { ConfigModule, ConfigService } from '@nestjs/config'; import { PgQuerierPool } from 'uql-orm/postgres'; UqlModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: (config: ConfigService) => new PgQuerierPool({ connectionString: config.get('DATABASE_URL') }), }); ``` ## Injecting the pool ```ts import { Inject, Injectable } from '@nestjs/common'; import { UQL_QUERIER_POOL } from 'uql-orm/nestjs'; import type { QuerierPool, Query, UniversalQuerier } from 'uql-orm/type'; import { User } from './shared/models/index.js'; @Injectable() export class UsersService { constructor(@Inject(UQL_QUERIER_POOL) private readonly pool: QuerierPool) {} findMany(q: Query) { return this.pool.findMany(User, q); } create(user: User, db: UniversalQuerier = this.pool) { return db.insertOne(User, user); } } ``` The pool is the stateless, shareable resource, which is why it is the thing to own via DI. A `Querier` holds a connection and possibly an open transaction: as a singleton it would pin one connection for the app’s lifetime and share transaction state across requests; request-scoped, it would re-instantiate the whole provider graph per request. Accepting a [`UniversalQuerier`](https://uql-orm.dev/querying/querier.md#accept-a-universalquerier) that defaults to the pool is what lets two services share one commit, with no request-scoped providers and no interceptor owning the release: ```ts @Injectable() export class SignUpService { constructor( @Inject(UQL_QUERIER_POOL) private readonly pool: QuerierPool, private readonly users: UsersService, private readonly audit: AuditService, ) {} signUp(user: User) { return this.pool.transaction(async (querier) => { await this.users.create(user, querier); await this.audit.record({ type: 'user.created' }, querier); }); } } ``` `UQL_QUERIER_POOL` is the token to inject, and the one to pass to `querierMiddleware({ pool })` or `createFetchHandler({ pool })`. What you inject is what runs. ## Hand-written controllers A controller that takes a query off the wire is taking untrusted input. Nest 12 accepts any Standard Schema validator in `@Body`, `@Query` and `@Param`, so the narrow arguments an endpoint means to expose are checked before anything reaches the pool, and the query itself is built server-side: ```ts title="users.controller.ts" import { Body, Controller, Post } from '@nestjs/common'; import { z } from 'zod'; const searchInput = z.object({ status: z.enum(['active', 'blocked']), limit: z.number().int().max(100).default(20), }); @Controller('users') export class UsersController { constructor(private readonly users: UsersService) {} @Post('search') search(@Body({ schema: searchInput }) input: z.infer) { return this.users.findMany({ $where: { status: input.status }, $limit: input.limit, }); } } ``` Tenant isolation is a separate job: a `security` [filter](https://uql-orm.dev/multi-tenancy.md) does it below the query, where no controller can forget it. ## Multi-tenancy Pass `getContext` and UQL wires a global interceptor that runs every request inside `withContext`, so [`security` filters](https://uql-orm.dev/multi-tenancy.md) apply to every query, relations, cascades and transactions included. `forRoot` is generic in your request type, so naming the shape `getContext` reads types `req` inside it: ```ts type AuthedRequest = { user: { id: string; tenantId: number } }; UqlModule.forRoot({ pool, // derive from the verified request (session / JWT), never from client input getContext: (req) => ({ tenantId: req.user.tenantId, userId: req.user.id }), }); ``` The `@Filter({ security: true })` that consumes the context is plain UQL: see [Multi-tenancy](https://uql-orm.dev/multi-tenancy.md). An interceptor runs **after guards**, so `req.user` is populated, which is what you want for tenant-from-JWT. The context reaches controllers and services, but not guards or exception filters. If you derive it from a header or sub-domain, mount your own middleware instead: `withContext(getContext(req), () => next())`. ## Auto-generated entity routes Nest’s default platform is Express, so the [Express middleware](https://uql-orm.dev/express.md) mounts in `main.ts`: ```ts title="main.ts" import { NestFactory } from '@nestjs/core'; import { querierMiddleware } from 'uql-orm/express'; import { AppModule } from './app.module.js'; const app = await NestFactory.create(AppModule); app.enableShutdownHooks(); // lets UqlModule end the pool on SIGTERM app.use('/api', querierMiddleware({ pool, include: [User, Post] })); await app.listen(3000); ``` Unknown routes fall through, so hand-written controllers coexist under the same prefix. On the Fastify platform, use the [`createRequestHandler` bridge](https://uql-orm.dev/fastify.md). # Next.js > Use UQL in Next.js App Router server components, route handlers, and server actions. Source: https://uql-orm.dev/nextjs Anything that runs on the server in the App Router can query directly: a server component, a route handler, a server action. No adapter for any of them. Verified against Next.js 16, where Turbopack is the default for `next dev` and `next build`. ```sh npm install uql-orm pg server-only ``` ## Where the pool lives ```ts title="src/db/uql.ts" import 'server-only'; import { PgQuerierPool } from 'uql-orm/postgres'; import './entities'; // importing the module registers the decorated entities // the dev server re-evaluates this on every hot reload; a fresh pool per reload leaks connections declare global { var uqlPool: PgQuerierPool | undefined; } export const pool = (globalThis.uqlPool ??= new PgQuerierPool({ connectionString: process.env.DATABASE_URL, })); ``` `server-only` turns an accidental import from a client component into a build error rather than a bundled connection string. Server components and actions use the exported `pool`. UQL’s decorators are the standard TC39 ones, so there are no decorator flags to add and nothing for Turbopack to trip over: field types are stated explicitly (`@Field({ type: String })`) rather than reflected. Next’s generated `tsconfig.json` needs no changes either: it already ships `moduleResolution: bundler` and `esnext` in `lib`, which is everything [Requirements](https://uql-orm.dev/getting-started.md) asks for. ## Server components ```tsx title="app/users/page.tsx" import { pool } from '@/db/uql'; import { User } from '@/db/entities'; export default async function UsersPage() { const users = await pool.findMany(User, { $select: { id: true, name: true }, $populate: { posts: { $select: { title: true }, $where: { published: true }, $limit: 5, }, }, $sort: { createdAt: 'desc' }, $limit: 20, }); return (
    {users.map((user) => (
  • {user.name} ({user.posts.length})
  • ))}
); } ``` `users` is typed `User[]`, each with a typed `posts`. ## Route handlers For callers outside your React tree: a mobile client, a webhook, a third party. Keep the default Node.js runtime, since `pg` needs TCP. ```ts title="app/api/users/route.ts" import { NextResponse } from 'next/server'; import { pool } from '@/db/uql'; import { User } from '@/db/entities'; export async function GET() { const users = await pool.findMany(User, { $select: { id: true, name: true }, $limit: 20, }); return NextResponse.json(users); } ``` ## Server actions ```ts title="app/users/actions.ts" 'use server'; import { revalidatePath } from 'next/cache'; import { pool } from '@/db/uql'; import { Post, User } from '@/db/entities'; import { z } from 'zod'; const NewUser = z.object({ email: z.email(), name: z.string().trim().min(1), }); export async function createUser(formData: FormData) { const form = NewUser.safeParse(Object.fromEntries(formData)); if (!form.success) { return { errors: z.flattenError(form.error).fieldErrors }; } await pool.transaction(async (querier) => { const id = await querier.insertOne(User, form.data); await querier.insertOne(Post, { authorId: id, title: 'Hello' }); }); revalidatePath('/users'); } ``` An action is a public endpoint with a generated URL, so build the query from validated input and never hand a raw `Query` to the pool. Writes that must land together go in [`pool.transaction`](https://uql-orm.dev/querying/transactions.md). ## Auto-generated CRUD ```ts title="app/api/uql/[[...uql]]/route.ts" import { createFetchHandler } from 'uql-orm/http'; import { pool } from '@/db/uql'; import { User } from '@/db/entities'; const handler = createFetchHandler({ pool, include: [User], basePath: '/api/uql', }); export { handler as GET, handler as HEAD, handler as POST, handler as PUT, handler as PATCH, handler as DELETE, }; ``` Next.js does not strip the prefix, hence `basePath`. Every entity now has typed REST endpoints (`/api/uql/user`, …) for [`HttpQuerier`](https://uql-orm.dev/browser.md). Route handler exports are named after the standard verbs, so the [`QUERY` transport](https://uql-orm.dev/http.md#http-query-rfc-10008) is not available here; keep the client on `GET`. ## Multi-tenancy ```ts title="src/db/withTenant.ts" import 'server-only'; import { withContext } from 'uql-orm'; import { getSession } from '@/auth'; export async function withTenant(run: () => Promise): Promise { const session = await getSession(); // verified cookie or JWT, never a client-supplied id return session ? withContext({ tenantId: session.tenantId, userId: session.userId }, run) : run(); } ``` ```tsx const invoices = await withTenant(() => pool.findMany(Invoice, { $limit: 50 })); ``` `withContext` propagates across every `await` inside the callback, so a `security` [filter](https://uql-orm.dev/querying/filters.md) scopes each query, relations and cascades included, and fails closed without a context. Middleware cannot do this: it runs before the request and returns, so the store is gone by the time anything queries. The CRUD route gets the same treatment from `createFetchHandler`’s `getContext`. See [Multi-tenancy](https://uql-orm.dev/multi-tenancy.md). > **Vercel** > > On Fluid compute, register the inner `pg` pool so idle connections close before the function suspends: `attachDatabasePool(pool.pool)` from `@vercel/functions`. See [Serverless](https://uql-orm.dev/serverless.md). # React Router > Use UQL in React Router framework mode loaders, actions, resource routes, and middleware. Source: https://uql-orm.dev/react-router Framework mode (what Remix became) is fetch-native throughout: loaders and actions receive a web `Request` and may return a `Response`, and middleware wraps the request. No adapter anywhere. Written for v8; v7 differs only in that middleware was still behind a future flag there. ## Where the pool lives The `.server.ts` suffix is a hard boundary: the Vite plugin strips those modules from the client bundle, so importing one from a component is a build error rather than a leaked connection string. ```ts title="app/db.server.ts" import { PgQuerierPool } from 'uql-orm/postgres'; import './models'; export const pool = new PgQuerierPool({ connectionString: process.env.DATABASE_URL, }); ``` Loaders and actions use the exported `pool`. ## Loaders and actions ```tsx title="app/routes/posts.tsx" import { pool } from '~/db.server'; import { Post } from '~/models'; import type { Route } from './+types/posts'; export async function loader({ request }: Route.LoaderArgs) { const skip = Number(new URL(request.url).searchParams.get('skip')) || 0; return { posts: await pool.findMany(Post, { $select: { id: true, title: true }, $populate: { author: { $select: { name: true } } }, $where: { published: true }, $sort: { createdAt: 'desc' }, $limit: 20, $skip: skip, }), }; } export default function Posts({ loaderData }: Route.ComponentProps) { return (
    {loaderData.posts.map((post) => (
  • {post.title} - {post.author.name}
  • ))}
); } ``` `loaderData` is typed from the loader’s return type, each post with a typed `author`. Rows are plain objects, so there is nothing to map for the single-fetch serializer. ```ts title="app/routes/posts.new.tsx" import { redirect } from 'react-router'; import { pool } from '~/db.server'; import { Post } from '~/models'; import type { Route } from './+types/posts.new'; import { z } from 'zod'; const NewPost = z.object({ title: z.string().trim().min(1) }); export async function action({ request }: Route.ActionArgs) { const form = NewPost.safeParse(Object.fromEntries(await request.formData())); if (!form.success) { return { errors: z.flattenError(form.error).fieldErrors }; } return redirect(`/posts/${await pool.insertOne(Post, form.data)}`); } ``` An action is a public endpoint: build the query from validated input, never from a raw `Query`. Several writes go in [`pool.transaction`](https://uql-orm.dev/querying/transactions.md). ## Auto-generated CRUD A route module with no default export is a resource route. Point a splat at one: ```ts title="app/routes.ts" import { type RouteConfig, index, route } from '@react-router/dev/routes'; export default [ index('routes/home.tsx'), route('api/uql/*', 'routes/uql.ts'), ] satisfies RouteConfig; ``` ```ts title="app/routes/uql.ts" import { createFetchHandler } from 'uql-orm/http'; import { pool } from '~/db.server'; import { Post, User } from '~/models'; import type { Route } from './+types/uql'; const handler = createFetchHandler({ pool, include: [Post, User], basePath: '/api/uql', }); export const loader = ({ request }: Route.LoaderArgs) => handler(request); export const action = ({ request }: Route.ActionArgs) => handler(request); ``` React Router does not strip the prefix, hence `basePath`. `loader` serves `GET` and `HEAD`, `action` serves the write verbs, which is the whole [wire protocol](https://uql-orm.dev/http.md#wire-protocol) minus the [`QUERY` transport](https://uql-orm.dev/http.md#http-query-rfc-10008): that split is keyed to named verbs, so keep the [browser client](https://uql-orm.dev/browser.md) on `GET`. ## Multi-tenancy Middleware wraps `next()`, so `withContext` drops straight in. On the root route it covers every loader, action and resource route below: ```ts title="app/root.tsx" import { withContext } from 'uql-orm'; import type { Route } from './+types/root'; export const middleware: Route.MiddlewareFunction[] = [ async ({ request }, next) => { const session = await getSession(request.headers.get('cookie')); return session ? withContext( { tenantId: session.tenantId, userId: session.userId }, () => next(), ) : next(); }, ]; ``` A `security` [filter](https://uql-orm.dev/querying/filters.md) then scopes every query, relations and cascades included, and fails closed without a context. See [Multi-tenancy](https://uql-orm.dev/multi-tenancy.md). Data mode and SPA mode have no server in the router: mount the [HTTP core](https://uql-orm.dev/http.md) on whatever hosts your API and talk to it with [`HttpQuerier`](https://uql-orm.dev/browser.md) or the [TanStack Query recipe](https://uql-orm.dev/react-query.md). # Astro > Query UQL from Astro pages, server islands, actions, and a catch-all API endpoint. Source: https://uql-orm.dev/astro A page can query the database in its frontmatter with no API layer in between. Nothing to install beyond `uql-orm`. Verified against Astro 7. Database access needs [on-demand rendering](https://docs.astro.build/en/guides/on-demand-rendering/): add an adapter, then set `output: 'server'` or opt routes in with `export const prerender = false`. ## Where the pool lives ```ts title="src/lib/uql.ts" import { PgQuerierPool } from 'uql-orm/postgres'; import { DATABASE_URL } from 'astro:env/server'; export const pool = new PgQuerierPool({ connectionString: DATABASE_URL }); ``` Pages, actions and endpoints use the exported `pool`. Declare `DATABASE_URL` in `env.schema` as `envField.string({ context: 'server', access: 'secret' })`: it stays off the client and a missing value fails the build instead of the first query in production. ## Pages ```astro title="src/pages/posts/index.astro" --- import { pool } from '../../lib/uql'; import { Post } from '../../lib/models'; export const prerender = false; const posts = await pool.findMany(Post, { $select: { id: true, title: true }, $populate: { author: { $select: { name: true } } }, $where: { published: true }, $sort: { createdAt: 'desc' }, $limit: 20, }); ---
    {posts.map((post) =>
  • {post.title} - {post.author.name}
  • )}
``` ## Caching Route caching, stable in Astro 7, is what stops the page above querying on every request. Name a provider once and set the defaults per URL pattern: ```ts title="astro.config.ts" import { cacheVercel } from '@astrojs/vercel/cache'; import { defineConfig } from 'astro/config'; export default defineConfig({ cache: { provider: cacheVercel() }, routeRules: { '/posts': { maxAge: 60, swr: 300 } }, }); ``` A page overrides its rule and tags the response with `Astro.cache.set({ maxAge: 60, swr: 300, tags: ['posts'] })`, so a write drops only what it touched: ```ts await pool.updateOneById(Post, id, { published: true }); await context.cache.invalidate({ tags: ['posts'] }); ``` `memoryCache()` from `astro/config` caches only inside the instance that served the request, so on functions it barely reduces queries; the adapter’s own provider does (`cacheVercel()`, `cacheNetlify()`, `cacheCloudflare()`). Either way the key is the URL and not your tenant context, so per-user data belongs in a server island or behind `Astro.cache.set(false)`, `security` [filter](https://uql-orm.dev/querying/filters.md) or no. ## Server islands Per-user data on a cached page: defer the component and it renders in its own request. ```astro title="src/components/RecentOrders.astro" --- import { pool } from '../lib/uql'; import { Order } from '../lib/models'; const { user } = Astro.locals; const orders = user ? await pool.findMany(Order, { $where: { customerId: user.id }, $sort: { createdAt: 'desc' }, $limit: 5 }) : []; ---
    {orders.map((order) =>
  • {order.reference}
  • )}
``` ```astro title="src/pages/account.astro"

Loading your orders...

``` ## Actions The examples below write comments, so `src/lib/models.ts` exports one alongside `Post`: ```ts title="src/lib/models.ts" import { Entity, Field, Id, ManyToOne } from 'uql-orm'; @Entity() export class Comment { @Id({ type: Number }) id?: number; @Field({ type: String }) body?: string | null; @Field({ references: () => Post }) postId?: number | null; @Field({ type: Number }) authorId?: number | null; @ManyToOne({ entity: () => Post, references: (comment) => comment.postId }) post?: Post; } ``` ```ts title="src/actions/index.ts" import { ActionError, defineAction } from 'astro:actions'; import { z } from 'astro/zod'; import { pool } from '../lib/uql'; import { Comment } from '../lib/models'; export const server = { addComment: defineAction({ accept: 'form', input: z.object({ postId: z.coerce.number(), body: z.string().min(1) }), handler: async ({ postId, body }, context) => { if (!context.locals.user) { throw new ActionError({ code: 'UNAUTHORIZED', message: 'Sign in to comment.', }); } const id = await pool.insertOne(Comment, { postId, body, authorId: context.locals.user.id, }); return pool.findOneById(Comment, id); }, }), }; ``` An action is a public endpoint: build the query from validated input, never from a raw `Query`. Several writes go in [`pool.transaction`](https://uql-orm.dev/querying/transactions.md). ## Auto-generated CRUD ```ts title="src/pages/api/uql/[...uql].ts" import type { APIRoute } from 'astro'; import { createFetchHandler } from 'uql-orm/http'; import { pool } from '../../../lib/uql'; import { Comment, Post } from '../../../lib/models'; export const prerender = false; const handler = createFetchHandler({ pool, include: [Post, Comment], basePath: '/api/uql', }); export const ALL: APIRoute = ({ request }) => handler(request); ``` Astro does not strip the prefix, hence `basePath`. `ALL` catches every method, so the [`QUERY` transport](https://uql-orm.dev/http.md#http-query-rfc-10008) works too, and the endpoints are consumable with [`HttpQuerier`](https://uql-orm.dev/browser.md). Astro 7’s `src/fetch.ts` takes the same handler, if you would rather own the request pipeline than sit in the route table: ```ts title="src/fetch.ts" import type { Fetchable } from 'astro'; import { astro, FetchState } from 'astro/fetch'; import { withContext } from 'uql-orm'; import { createFetchHandler } from 'uql-orm/http'; import { pool } from './lib/uql'; import { Comment, Post } from './lib/models'; const uql = createFetchHandler({ pool, include: [Post, Comment], basePath: '/api/uql', }); export default { async fetch(request: Request) { const state = new FetchState(request); if (!state.pathname.startsWith('/api/uql')) return astro(state); const session = await getSession(state.cookies); return session ? withContext({ tenantId: session.tenantId }, () => uql(request)) : uql(request); }, } satisfies Fetchable; ``` `astro(state)` is everything Astro would have done: middleware, actions, caching, sessions, pages. Answering ahead of it skips all of that, `src/middleware.ts` included, which is why the tenant context is set here by hand. ## Multi-tenancy ```ts title="src/middleware.ts" import { defineMiddleware } from 'astro:middleware'; import { withContext } from 'uql-orm'; export const onRequest = defineMiddleware(async (context, next) => { const session = await getSession(context.cookies); context.locals.user = session?.user; return session ? withContext({ tenantId: session.tenantId }, () => next()) : next(); }); ``` Astro middleware wraps `next()`, so pages, islands, actions and the API endpoint are all scoped by the same `security` [filter](https://uql-orm.dev/querying/filters.md). See [Multi-tenancy](https://uql-orm.dev/multi-tenancy.md). Astro 7 removed `@astrojs/db`, and its upgrade guide sends you to `node:sqlite`, Drizzle or a hosted database. UQL covers those without a different query layer per database: [Turso or libSQL](https://uql-orm.dev/turso.md), a [local SQLite file](https://uql-orm.dev/sqlite.md), [Postgres](https://uql-orm.dev/postgres.md), or [PGlite](https://uql-orm.dev/pglite.md) for Postgres itself with no server to run. Only the pool changes. # TanStack Start > Use UQL in TanStack Start via type-safe server functions and a catch-all server route that mounts the HTTP transport core. Source: https://uql-orm.dev/tanstack-start Start is full-stack and fetch-native, so there are two ways in and they coexist: call the pool from type-safe **server functions**, or mount the [HTTP core](https://uql-orm.dev/http.md) as a catch-all **server route**. Nothing to install beyond `uql-orm`. ## Server functions A UQL query is plain JSON, so it passes through as the validated input with end-to-end types: ```ts import { createServerFn } from '@tanstack/react-start'; import type { WireQuery } from 'uql-orm/type'; import { pool } from './uql.config.js'; import { User } from './shared/models/index.js'; export const listUsers = createServerFn({ method: 'GET' }) .validator((query: WireQuery) => query) // type-only pass-through .handler(({ data }) => pool.findMany(User, data)); ``` ```ts const users = await listUsers({ data: { $select: { id: true, name: true }, $where: { status: 'active' }, $populate: { posts: { $select: { title: true }, $where: { published: true }, $limit: 5, }, }, $limit: 10, }, }); // typed User[], each with a typed posts: Post[] ``` `(query) => query` declares the type without checking it, the same trust model as [tRPC](https://uql-orm.dev/trpc.md) and [oRPC](https://uql-orm.dev/orpc.md). For untrusted callers, validate with a schema and build the query server-side; for tenant isolation run the handler inside `withContext` with a `security` [filter](https://uql-orm.dev/querying/filters.md). See [Multi-tenancy](https://uql-orm.dev/multi-tenancy.md). A server function that writes more than one row wraps its statements in [`pool.transaction`](https://uql-orm.dev/querying/transactions.md) and runs each on the callback’s `querier`: a `pool.x` call inside there takes a second connection, so it lands outside the transaction. ## Auto-generated CRUD ```ts title="src/routes/api/uql/$.ts" import { createFileRoute } from '@tanstack/react-router'; import { createFetchHandler } from 'uql-orm/http'; import { pool } from './uql.config.js'; import { Post, User } from './shared/models/index.js'; const handler = createFetchHandler({ pool, include: [User, Post], basePath: '/api/uql', }); export const Route = createFileRoute('/api/uql/$')({ server: { handlers: { GET: ({ request }) => handler(request), HEAD: ({ request }) => handler(request), POST: ({ request }) => handler(request), PUT: ({ request }) => handler(request), PATCH: ({ request }) => handler(request), DELETE: ({ request }) => handler(request), }, }, }); ``` Start does not strip the prefix, hence `basePath`. Every entity now has typed REST endpoints (`/api/uql/user`, …) for [`HttpQuerier`](https://uql-orm.dev/browser.md). The `handlers` keys are standard verbs, so the [`QUERY` transport](https://uql-orm.dev/http.md#http-query-rfc-10008) is not routed here; keep `GET`. `createFetchHandler` takes the core’s [hooks](https://uql-orm.dev/http.md#authorization-hooks), with the web `Request` as the hook context, and `getContext` for tenant scoping. Use server functions for typed per-operation calls and the catch-all route for CRUD across many entities. The query object is identical either way: [one query, every transport](https://uql-orm.dev/querying/querier.md#the-same-query-every-transport). # tRPC > Expose UQL entities as tRPC procedures with the serializable JSON query as procedure input. Source: https://uql-orm.dev/trpc UQL queries are plain JSON, so they pass through tRPC procedures without any adapter. Nothing to install beyond your existing tRPC v11 setup: procedures call the querier pool directly. ```ts title="src/router.ts" import { initTRPC } from '@trpc/server'; import { z } from 'zod'; import type { EntityData, Type, WireQuery } from 'uql-orm/type'; import { pool } from './uql.config.js'; import { User } from './shared/models/index.js'; const t = initTRPC.create(); function entityRouter(entity: Type) { return t.router({ findMany: t.procedure .input(z.custom>()) // declares the input type; no cast, no per-procedure schema .query(({ input }) => pool.findMany(entity, input)), insertOne: t.procedure .input(z.object({ data: z.custom>() })) // an object: tRPC cannot type a bare generic input .mutation(({ input }) => pool.insertOne(entity, input.data)), }); } export const appRouter = t.router({ user: entityRouter(User), }); export type AppRouter = typeof appRouter; ``` On the client the whole query is typed end to end, filters, sorting and nested relation loading alike, and reaches the server as plain JSON with no per-procedure schema to keep in sync: ```ts title="src/client.ts" import { createTRPCClient, httpBatchLink } from '@trpc/client'; import type { AppRouter } from './router.js'; const trpc = createTRPCClient({ links: [httpBatchLink({ url: '/trpc' })], }); const users = await trpc.user.findMany.query({ $select: { id: true, name: true }, $where: { status: 'active', email: { $endsWith: '@domain.com' } }, $populate: { posts: { $select: { title: true }, $where: { published: true }, $limit: 5 }, }, $limit: 10, }); // typed User[], each with a typed posts: Post[] ``` > **Validate public inputs** > > `z.custom()` declares the type without checking it at runtime, so it trusts the caller’s shape. For procedures reachable by untrusted clients, take narrow arguments and build the query server-side instead. For tenant isolation, wrap the procedure body in `withContext(getContext(ctx), () => ...)` with a `security` [filter](https://uql-orm.dev/querying/filters.md), non-bypassable and fail-closed, rather than folding tenant filters into `$where` by hand. See [Multi-tenancy](https://uql-orm.dev/multi-tenancy.md). A procedure that writes more than one row wraps its statements in [`pool.transaction`](https://uql-orm.dev/querying/transactions.md) and runs each on the callback’s `querier`: a `pool.x` call inside there takes a second connection, so it lands outside the transaction. Prefer tRPC when you want per-procedure contracts and its client tooling; prefer the [HTTP core](https://uql-orm.dev/http.md) when you want zero-boilerplate CRUD for many entities. They compose fine in one app, and the query object is identical either way: [one query, every transport](https://uql-orm.dev/querying/querier.md#the-same-query-every-transport). # oRPC > Expose UQL entities as oRPC procedures with type-safe pass-through inputs. Source: https://uql-orm.dev/orpc UQL queries are plain JSON, so they pass through [oRPC](https://orpc.dev) procedures without any adapter. There is nothing to install beyond your existing oRPC setup: procedures call the querier pool directly, and oRPC’s `type()` helper declares the pass-through input type. ```ts title="src/router.ts" import { os, type } from '@orpc/server'; import type { EntityData, Type, WireQuery } from 'uql-orm/type'; import { pool } from './uql.config.js'; import { User } from './shared/models/index.js'; function entityRouter(entity: Type) { return { findMany: os .input(type>()) .handler(({ input }) => pool.findMany(entity, input)), insertOne: os // the mapper is required here: type() alone cannot resolve a mapped type over a generic .input(type>((value) => value)) .handler(({ input }) => pool.insertOne(entity, input)), }; } export const router = { user: entityRouter(User) }; ``` On the client the whole query is typed end to end, filters, sorting and nested relation loading alike, and reaches the server as plain JSON with no per-procedure schema to keep in sync: ```ts title="src/client.ts" import { createORPCClient } from '@orpc/client'; import { RPCLink } from '@orpc/client/fetch'; import type { RouterClient } from '@orpc/server'; import type { router } from './router.js'; const client: RouterClient = createORPCClient( new RPCLink({ url: '/rpc' }), ); const users = await client.user.findMany({ $select: { id: true, name: true }, $where: { status: 'active', email: { $endsWith: '@domain.com' } }, $populate: { posts: { $select: { title: true }, $where: { published: true }, $limit: 5 }, }, $limit: 10, }); // typed User[], each with a typed posts: Post[] ``` > **Validate public inputs** > > `type()` declares the input type without runtime validation, the same trust model as `z.custom()` in the [tRPC recipe](https://uql-orm.dev/trpc.md). For procedures exposed to untrusted clients, validate with a schema (e.g. zod) and scope the query server-side. For tenant isolation, wrap the handler in `withContext(getContext(ctx), () => ...)` with a `security` [filter](https://uql-orm.dev/querying/filters.md), non-bypassable and fail-closed, rather than hand-folding tenant filters into `$where`; see [Multi-tenancy](https://uql-orm.dev/multi-tenancy.md). oRPC also generates OpenAPI specs from real schemas only, so type-only inputs are excluded from them. A handler that writes more than one row wraps its statements in [`pool.transaction`](https://uql-orm.dev/querying/transactions.md) and runs each on the callback’s `querier`: a `pool.x` call inside there takes a second connection, so it lands outside the transaction. Prefer oRPC or [tRPC](https://uql-orm.dev/trpc.md) when you want per-procedure contracts; prefer the [HTTP core](https://uql-orm.dev/http.md) for zero-boilerplate CRUD across many entities. oRPC’s `RPCHandler` is fetch-native, so both mount side by side in one app, and the query object is identical either way: [one query, every transport](https://uql-orm.dev/querying/querier.md#the-same-query-every-transport). # Browser > Run type-safe UQL queries from the browser, either against your API with HttpQuerier or against Postgres itself with PGlite. Source: https://uql-orm.dev/browser The browser can be either end of a UQL query. `uql-orm/browser` talks to your API over HTTP, and `uql-orm/pglite` skips the network by running Postgres in the tab. ## HTTP client `uql-orm/browser` consumes the REST API served by the [HTTP core](https://uql-orm.dev/http.md) or the [Express adapter](https://uql-orm.dev/express.md), using the same query syntax you write on the server. It is optional, and it is an HTTP client rather than a driver: something on the other end still holds the connection. ```ts import { HttpQuerier } from 'uql-orm/browser'; import { User } from './shared/models/index.js'; const querier = new HttpQuerier('https://api.yourdomain.com/api'); const { data: users } = await querier.findMany(User, { $select: { email: true }, $populate: { profile: { $select: { picture: true } } }, $where: { email: { $endsWith: '@domain.com' } }, $sort: { createdAt: 'desc' }, $limit: 10, }); // typed User[], each with a typed profile ``` Entity classes are shared between backend and frontend, so the query type-checks identically on both sides. What leaves the browser is JSON, so the client takes a [`WireQuery`](https://uql-orm.dev/querying/querier.md#the-same-query-every-transport): the query without [`raw`](https://uql-orm.dev/querying/raw-sql.md) SQL. A `raw` fragment or a binary value is refused rather than mangled; both belong in a route of your own. ### Client API Every wire operation has a typed method: `findMany`, `findManyAndCount`, `findOne`, `findOneById`, `count`, `insertOne`, `insertMany`, `saveOne` (upsert via `PUT`), `saveMany`, `updateOneById`, `updateMany`, `deleteOneById`, `deleteMany`. Responses are `{ data, count? }`. URLs derive from the shared `CRUD_ROUTES` contract in `uql-orm/http`, and a compile-time check guarantees the client covers every operation, so the mapping cannot drift from the server. Failed requests throw a `RequestError` carrying the server’s message and the numeric HTTP `status`, so status-driven flows work without string matching: ```ts import { RequestError } from 'uql-orm/browser'; try { await querier.findMany(User, {}); } catch (err) { if (err instanceof RequestError && err.status === 401) { location.href = '/login'; } } ``` ### Options ```ts // per instance: defaults that per-call options override const querier = new HttpQuerier('/api', { headers: { Authorization: `Bearer ${session.token}` }, }); // per call: abort/timeout, headers, and `silent` to skip the notification bus await querier.findMany( User, { $limit: 20 }, { signal: AbortSignal.timeout(120_000), headers: { Authorization: `Bearer ${session.token}` }, }, ); ``` Build one scoped instance per server-side request rather than reusing a module-level one, so a token never leaks across requests. For non-CRUD endpoints (`/api/payments/checkout`, …), the typed helpers `get`, `post`, `put`, `patch`, `remove` and `query` are exported too, sharing the same envelope, headers, notifications and `RequestError`. ### HTTP QUERY transport Opt in to send read queries in the request body instead of the URL, which sidesteps URL-length limits on large `$where`/`$populate`: ```ts const querier = new HttpQuerier('/api', { readMethod: 'QUERY' }); ``` `findOne`, `findMany` and `count` then use [`QUERY`](https://uql-orm.dev/http.md#http-query-rfc-10008); writes and by-id reads keep their canonical methods. The default stays `GET` because cross-origin `QUERY` needs a CORS preflight and some proxies still drop the method. The server accepts both at once, so this is a per-client switch. ### Request notifications A small pub/sub bus (`on`) emits `start`, `success`, `error` and `complete` per request, which is enough for a global spinner in a vanilla app. Libraries that already track loading state do not need it: pass `{ silent: true }`. See the [TanStack Query recipe](https://uql-orm.dev/react-query.md), where the serializable query doubles as the cache key. ## Postgres in the tab [PGlite](https://uql-orm.dev/pglite.md) is Postgres compiled to WASM, and `idb://` is its browser `dataDir`, so `PgliteQuerierPool` runs in a page with no server behind it. Same entities, same queries, same migrations as the server, because it reports itself as the `postgres` dialect: ```ts import { PgliteQuerierPool } from 'uql-orm/pglite'; import { User } from './shared/models/index.js'; const pool = new PgliteQuerierPool('idb://app'); const users = await pool.findMany(User, { $select: { id: true, email: true }, }); ``` The database survives a reload: `idb://` persists into IndexedDB. Two things your bundler has to get right, each with its own failure: - **Emit PGlite’s assets.** It fetches `pglite.wasm`, `initdb.wasm` and `pglite.data` from the page’s origin at runtime. When they 404, the first query throws `Invalid FS bundle size`. - **Set a dated `target`.** No browser implements decorators natively, so `esnext` leaves the syntax in and the page dies on `Invalid or unexpected token` before any of your code runs. Same [requirement](https://uql-orm.dev/getting-started.md) as on the server. One connection means one transaction at a time, which for a single tab is rarely the limit it sounds like. [PGlite](https://uql-orm.dev/pglite.md#one-connection-and-what-follows-from-it) has the rest. # TanStack Query > Use UQL's serializable queries as TanStack Query cache keys, with query options factories, pagination, mutations, optimistic updates and SSR hydration. Source: https://uql-orm.dev/react-query A UQL query is a plain JSON object, which makes it a **structural `queryKey`**: two components asking for the same data share one cache entry, and there are no key strings to keep in sync. TanStack hashes keys with object keys sorted, so an inline literal is stable across renders without `useMemo`. The examples below are React with v5, but none of it is React-specific: [`uql-orm/browser`](https://uql-orm.dev/browser.md) is a plain client, so the same keys and fetchers work through the Vue, Svelte and Solid adapters. ## Setup One client for the wire, one for the cache. ```ts title="src/lib/uql.ts" import { HttpQuerier } from 'uql-orm/browser'; export const querier = new HttpQuerier('/api'); ``` ```ts title="src/lib/queryClient.ts" import { QueryClient, defaultShouldDehydrateQuery, environmentManager, } from '@tanstack/react-query'; import { RequestError } from 'uql-orm/browser'; const makeQueryClient = () => new QueryClient({ defaultOptions: { queries: { staleTime: 60_000, retry: (count, err) => count < 3 && !(err instanceof RequestError && err.status < 500), }, // Dehydrate queries that are still pending, so a server prefetch can // stream in rather than hold the page back. See Server rendering below. dehydrate: { shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === 'pending', }, }, }); let browserQueryClient: QueryClient | undefined; export function getQueryClient() { if (environmentManager.isServer()) { return makeQueryClient(); } browserQueryClient ??= makeQueryClient(); return browserQueryClient; } ``` `RequestError` carries the HTTP `status`, so a 4xx spends no retries; put `429` back in the retryable set if your API rate-limits. `environmentManager.isServer()` replaced the `isServer` boolean in v5.101; it is what gives every server render its own client and the browser tab a single one. ## The key and the fetcher `queryOptions` ties a key to the fetcher that fills it, so a component, a prefetch and an invalidation cannot disagree about either. Write the UQL query beside it and both come from one literal: ```ts title="src/lib/userQueries.ts" import { queryOptions } from '@tanstack/react-query'; import type { Query } from 'uql-orm/type'; import { User } from './models.js'; import { querier } from './uql.js'; export const activeUsers = { $select: { id: true, name: true, email: true }, $where: { status: 'active' }, $sort: { createdAt: 'desc' }, $limit: 20, } satisfies Query; export const activeUsersOptions = queryOptions({ queryKey: [User.name, activeUsers], queryFn: async ({ signal }) => { const { data } = await querier.findMany(User, activeUsers, { signal, silent: true, }); return data; }, }); ``` `signal` aborts the request with the component that started it, and `silent: true` skips UQL’s notification bus, since React Query already owns loading and error state. ```tsx const { data: users = [] } = useQuery(activeUsersOptions); // or, inside a Suspense boundary, where `data` is never undefined const { data: users } = useSuspenseQuery(activeUsersOptions); ``` `users` is `{ id, name, email }[]`: the query asked for three columns, so `user.status` is a compile error. That lasts only while `findMany` can see the literal. The obvious next step, a generic `useFindMany(entity, q: Query)`, quietly undoes it: `Query` is the unprojected type, so every row widens back to a whole `User`. See [Type Safety](https://uql-orm.dev/type-safety.md). ## Pagination `$skip` and `$limit` live in the query, so they live in the key. Each page caches separately, and `keepPreviousData` holds the current one on screen while the next loads: ```ts title="src/lib/userQueries.ts" import { keepPreviousData, queryOptions } from '@tanstack/react-query'; export const usersPage = (page: number) => { const q = { $sort: { createdAt: 'desc' }, $limit: 20, $skip: page * 20, } satisfies Query; return queryOptions({ queryKey: [User.name, q], queryFn: async ({ signal }) => { const { data, count } = await querier.findManyAndCount(User, q, { signal, silent: true, }); return { rows: data, total: count }; }, placeholderData: keepPreviousData, }); }; ``` `findManyAndCount` brings the rows and the unpaged total back in one round trip; naming them in the fetcher keeps `data.data` out of your components. An infinite list is the same query with the offset lifted out of the key, so the pages of one list share an entry and a different filter gets its own. That entry holds `{ pages, pageParams }` rather than rows, so it needs its own marker in the key, or a plain `useQuery` on the same filter would collide with it. ```ts title="src/lib/userQueries.ts" import { infiniteQueryOptions } from '@tanstack/react-query'; const feed = { $where: { status: 'active' }, $sort: { createdAt: 'desc' }, $limit: 20, } satisfies Query; export const userFeedOptions = infiniteQueryOptions({ queryKey: [User.name, 'infinite', feed], initialPageParam: 0, queryFn: async ({ pageParam, signal }) => { const { data } = await querier.findMany( User, { ...feed, $skip: pageParam }, { signal, silent: true }, ); return data; }, getNextPageParam: (lastPage, allPages) => lastPage.length < feed.$limit ? undefined : allPages.length * feed.$limit, getPreviousPageParam: (_firstPage, _allPages, firstPageParam) => firstPageParam > 0 ? firstPageParam - feed.$limit : undefined, // Cap what the cache holds: refetching a long feed is one request per page. maxPages: 5, }); ``` ```tsx const { data, fetchNextPage, hasNextPage } = useInfiniteQuery(userFeedOptions); ``` ## Mutations Keys match by prefix, so invalidation picks its own granularity: `[User.name]` drops every query for that entity, infinite lists included, while `activeUsersOptions.queryKey` drops exactly one. ```tsx import { useMutation, useQueryClient } from '@tanstack/react-query'; import type { EntityData } from 'uql-orm/type'; import { User } from './models.js'; import { querier } from './uql.js'; export function useInsertUser() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (payload: EntityData) => querier.insertOne(User, payload), onSuccess: () => queryClient.invalidateQueries({ queryKey: [User.name] }), }); } ``` To move the list before the server answers, read and write it through the same options object: `queryOptions` types its own `queryKey`, so the updater is checked against `{ id, name, email }[]`. ```tsx export function useRenameUser() { const queryClient = useQueryClient(); const { queryKey } = activeUsersOptions; return useMutation({ mutationFn: ({ id, name }: { id: number; name: string }) => querier.updateOneById(User, id, { name }), onMutate: async ({ id, name }) => { await queryClient.cancelQueries({ queryKey }); const previous = queryClient.getQueryData(queryKey); queryClient.setQueryData(queryKey, (rows = []) => rows.map((row) => (row.id === id ? { ...row, name } : row)), ); return { previous }; }, onError: (_err, _vars, context) => queryClient.setQueryData(queryKey, context?.previous), onSettled: () => queryClient.invalidateQueries({ queryKey }), }); } ``` ## Server rendering Prefetch with the **server** pool, hydrate into the client cache. Only the transport changes: the key and the literal are the ones the component already imports, so the browser wakes up holding the entry it was about to ask for. ```tsx title="app/users/page.tsx" import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import { pool } from '@/lib/uql.config'; // the server pool, not HttpQuerier import { getQueryClient } from '@/lib/queryClient'; import { activeUsers, activeUsersOptions } from '@/lib/userQueries'; import { User } from '@/lib/models'; export default function Page() { const queryClient = getQueryClient(); void queryClient .query({ ...activeUsersOptions, queryFn: () => pool.findMany(User, activeUsers), }) .catch(() => {}); return ( ); } ``` `queryClient.query()` is what `prefetchQuery` became: it resolves with the data or throws, so a prefetch swallows the rejection and leaves the retry to the client. Nothing is awaited, which is what `shouldDehydrateQuery` bought: the page streams, and the pending entry lands in the hydrated cache when the database answers. `ActiveUsers` then calls `useQuery(activeUsersOptions)` and finds it there. The pool returns `User[]` while `HttpQuerier` wraps it in `{ data }`, so the client fetcher unwraps to keep both halves the same shape. For per-request auth, scope a client instead of reusing the module-level one: `new HttpQuerier('/api', { headers })`. See the [browser client](https://uql-orm.dev/browser.md). # In search of the fastest TypeScript ORM > A SQL-generation speed benchmark across 6 TypeScript ORMs and query builders (Drizzle, Knex, MikroORM, Sequelize, TypeORM, and UQL), with a reproducible open-source methodology. Source: https://uql-orm.dev/blog/in-search-of-the-fastest-typescript-orm > **Superseded** > > This post measures pure SQL-generation speed, with no database involved. That benchmark has since been retired: it turned out to account for well under 0.5% of a real request, so it stopped being a meaningful way to compare ORMs. The [benchmark page](https://uql-orm.dev/benchmark.md) and [what-orms-really-cost](https://uql-orm.dev/blog/what-orms-really-cost.md) now measure a full PostgreSQL round trip instead, and that is the current, maintained comparison. The numbers and prose below are kept as they were for the record, but no longer track any actively re-run benchmark. I kept seeing *“just use Drizzle, it’s lightweight”* and *“ORMs are slow, use a query builder”* repeated everywhere. So I decided to actually measure it. The benchmark measures **pure SQL generation speed**: no database, no network, no connection pool. That isolates the overhead the ORM adds to every request. 6 entries, 8 query types, 3 runs averaged, on an Apple Silicon M4. --- ## Methodology - **Environment:** Node.js v24.18.1, Apple M4 Pro, 3 runs averaged. - **Versions:** Latest stable of every entry as of August 2026 (TypeORM 1.1.0, MikroORM 7.1.9, Sequelize 6.37.8, Drizzle 0.45.2, Knex 3.3.0, UQL 0.24.0). - **Fairness:** Each ORM uses its most idiomatic API (QueryBuilder for TypeORM/MikroORM), which benefits them by skipping entity overhead. - **What’s measured:** Pure SQL string generation, with no database, no I/O, and no connection pool. This isolates ORM overhead only. - **Why no Prisma?** Prisma’s query compiler is Rust compiled to WebAssembly rather than pure JS/TS, and it exposes no public SQL-compilation API, so it cannot be measured this way. --- ## The results ### INSERT: 10 rows in batch | Entry | ops/sec | vs winner | | - | - | - | | UQL | 698K | 1.00x | | Knex | 463K | 0.66x | | Sequelize | 196K | 0.28x | | MikroORM | 111K | 0.16x | | TypeORM | 42K | 0.06x | | Drizzle | 12K | 0.02x | ### UPDATE: SET + WHERE | Entry | ops/sec | vs winner | | - | - | - | | UQL | 2,161K | 1.00x | | Knex | 695K | 0.32x | | TypeORM | 282K | 0.13x | | Sequelize | 236K | 0.11x | | MikroORM | 218K | 0.10x | | Drizzle | 79K | 0.04x | ### UPSERT: ON CONFLICT by id | Entry | ops/sec | vs winner | | - | - | - | | UQL | 691K | 1.00x | | Knex | 433K | 0.63x | | Sequelize | 327K | 0.47x | | TypeORM | 261K | 0.38x | | MikroORM | 260K | 0.38x | | Drizzle | 36K | 0.05x | ### DELETE: simple WHERE | Entry | ops/sec | vs winner | | - | - | - | | UQL | 3,996K | 1.00x | | Sequelize | 1,361K | 0.34x | | Knex | 1,084K | 0.27x | | TypeORM | 507K | 0.13x | | MikroORM | 263K | 0.07x | | Drizzle | 207K | 0.05x | ### SELECT: 1 field | Entry | ops/sec | vs winner | | - | - | - | | UQL | 4,675K | 1.00x | | Sequelize | 3,084K | 0.66x | | Knex | 1,092K | 0.23x | | TypeORM | 591K | 0.13x | | MikroORM | 565K | 0.12x | | Drizzle | 229K | 0.05x | ### SELECT: WHERE + SORT + LIMIT | Entry | ops/sec | vs winner | | - | - | - | | UQL | 1,365K | 1.00x | | Knex | 614K | 0.45x | | Sequelize | 381K | 0.28x | | TypeORM | 276K | 0.20x | | MikroORM | 73K | 0.05x | | Drizzle | 59K | 0.04x | ### SELECT: complex $or + operators | Entry | ops/sec | vs winner | | - | - | - | | UQL | 741K | 1.00x | | Knex | 243K | 0.33x | | TypeORM | 158K | 0.21x | | Sequelize | 150K | 0.20x | | Drizzle | 34K | 0.05x | | MikroORM | 28K | 0.04x | ### AGGREGATE: GROUP BY + COUNT + HAVING | Entry | ops/sec | vs winner | | - | - | - | | UQL | 1,482K | 1.00x | | Sequelize | 416K | 0.28x | | Knex | 304K | 0.21x | | TypeORM | 277K | 0.19x | | Drizzle | 74K | 0.05x | | MikroORM | 69K | 0.05x | **[Interactive charts](https://rogerpadilla.github.io/ts-orm-benchmark/chart.html)** --- ## Two things that surprised me **1. The “lightweight” option is the slowest thing in the benchmark.** Drizzle, marketed as lightweight, is slower than Sequelize (a full ORM from 2014) in every single category. The functional expression-tree approach creates more intermediate objects than Sequelize’s simple string concatenation. **2. A standalone query builder can’t beat a well-designed ORM.** Knex has zero entity/relation overhead; it’s just a SQL string builder. Yet UQL, a full ORM with entities, relations, and migrations, is faster in all 8 categories, by 1.5x on batch INSERT up to 4.9x on aggregates. The conventional wisdom (“ORMs are slow”) doesn’t hold when the ORM pre-computes its metadata and avoids intermediate allocations. --- ## How UQL gets there I got curious why the gap was so large, so I dug into the approach. Most ORMs figure out your schema at query time: *“What table does `User` map to? What column is `companyId`? Is it nullable?”* They answer these questions on every single query. UQL answers them once at startup. Field-to-column mappings, table names, and relation paths are all pre-computed into lookup tables before the first query runs. At query time, generating SQL is just reading from a cache. The other difference is allocation. When TypeORM builds a SELECT, it creates a QueryBuilder, then an expression tree, then walks the tree to produce SQL. UQL pushes SQL fragments directly into a string buffer, creating no intermediate objects and no garbage-collection pressure. --- ## When this matters in production Database latency is 1-50ms and ORM overhead is microseconds, so for a low-traffic app the difference is noise. It starts to matter at scale. Take the WHERE + SORT + LIMIT query at 1,000 req/s: UQL (1,365K ops/s) spends under 1ms of CPU per second generating SQL, while MikroORM (73K ops/s) spends roughly 14ms, about 19x more CPU for the same queries before a single byte hits the network. In serverless, where you pay per ms of CPU, that shows up on the bill; in containers, it shows up as horizontal scaling cost. --- ## Reproduce it Full disclosure: I’m the author of UQL. That’s exactly why I built the benchmark as an independent repo anyone can audit and reproduce. This SQL-generation benchmark itself is no longer maintained (see the note above); for the current, actively re-run benchmark and its repro steps, see the [benchmark page](https://uql-orm.dev/benchmark.md). There’s also a broader feature-by-feature comparison on the [comparison page](https://uql-orm.dev/comparison.md). --- --- *[UQL](https://uql-orm.dev/index.md) is a JSON-native TypeScript ORM for Node.js, Bun and Deno. Supports PostgreSQL, PGlite, MySQL, MariaDB, MSSQL, SQLite, CockroachDB, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.* # In search of the perfect TypeScript ORM > What makes a perfect TypeScript ORM? A breakdown of five features: serializable queries, native TypeScript, multi-level operators, one API across databases, and language-agnostic JSON syntax, and where today's ORMs fall short. Source: https://uql-orm.dev/blog/in-search-of-the-perfect-orm *Originally published on [Medium](https://medium.com/@rogerpadillac/in-search-of-the-perfect-orm-e01fcc9bce3d).* ## What is an ORM? An ORM provides a simpler way to interact with databases in an app: it lets developers work with data as objects. ## What is a perfect ORM for TypeScript? Below are the ideal features a TypeScript ORM should have, why such features are the most important ones, and how the existing ORMs fall short in most of these areas. **The top 5 features a perfect TypeScript ORM should have are:** ### 1. Serializable queries Queries that can travel between the layers of a system keep the design simple. If the client can send a query to the server over HTTP or websockets using the ORM’s own syntax, you no longer need an extra query language on top: the `GraphQL => ORM => Database` flow collapses to `ORM => Database`. Concretely: - No pseudo-language for queries and no context switching: the syntax is standard JSON, entirely declarative and serializable. - No additional servers and no extra steps in the build process. - Editors and IDEs understand the queries natively, without custom plugins or extensions. ### 2. Native TypeScript Use TypeScript itself for everything: JSON queries, classes, and decorators. - Type-safe queries and models that are natively validated by the same language that you use to write your app. - Context-aware queries allow auto-completion of the appropriate operators and fields according to the different parts of a query. - Entity definition with standard classes and decorators to avoid the need for proprietary DSLs (as happens with Prisma), extra steps in the build process, and custom extensions for the editors. ### 3. Multi-level operators Operations such as filter, sort, limit, project, and others work on any level of the queries (including relations and their fields). ### 4. Consistent API across databases Write the queries for any database in a consistent way and then transparently optimize these queries for the configured database dialect. ### 5. Universal syntax (language agnostic) Because the queries are standard JSON, they can be produced or consumed from any language: JSON is a first-class format in Python, Rust, Go, and virtually everything else. That also makes UQL implementations in other languages feasible. --- ## Why do the current top 3 TypeScript ORMs fall short? ### What does TypeORM lack to be a perfect ORM? **1. Lack of 100% serializable queries:** Notice how the `LessThan` operator is a function that has to be imported and called; that alone makes the query impossible to serialize. ```ts title="TypeORM" import { LessThan } from 'typeorm'; const loadedPosts = await dataSource.getRepository(Post).findBy({ likes: LessThan(10), }); ``` **2. Lack of Native TypeScript:** The query dissolves into strings because `relations` and `where` accept any string, so any invalid string can go there. ```ts title="TypeORM" const posts = await connection.manager.find(Post, { select: ['id'], relations: ['< anything can go here >'], }); ``` **3. Lack of consistent API across databases:** In the section that TypeORM has for MongoDB, there is a self-explanatory warning about this. **4. Lack of Universal Syntax (language agnostic):** It relies on closures to support advanced queries (and not every language out there supports closures). --- ### What does Prisma lack to be a perfect ORM? **1. Lack of Native TypeScript:** - Context-switching between the custom DSL and TypeScript makes this process obtrusive. - Extra steps in the build process to generate the corresponding files from the custom DSL. - It is required to install a custom extension for VS Code to get (basic) autocompletion from the editor for the DSL, which is far from being as good and reliable as with TypeScript. ```prisma datasource db { provider = "postgresql" url = env("DATABASE_URL") } generator client { provider = "prisma-client-js" } model User { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) email String @unique name String? role Role @default(USER) posts Post[] } ``` **2. Lack of consistent API across databases:** Prisma exposes low-level details about MongoDB that could be encapsulated by the ORM. ```prisma model User { id String @id @default(auto()) @map("_id") @db.ObjectId // Other fields } ``` **3. Lack of Universal Syntax (language agnostic):** It relies on its own proprietary DSL to define the models. --- ### What does MikroORM lack to be a perfect ORM? **1. Lack of 100% serializable queries:** Notice how that query uses two separate methods, one for the update and another for the where; that structure cannot be serialized. ```ts title="MikroORM" const qb = orm.em.createQueryBuilder(Author); qb.update({ name: 'test 123', type: PublisherType.GLOBAL }).where({ id: 123, type: PublisherType.LOCAL, }); ``` **2. Lack of Native TypeScript:** Dissolves into strings: any string can go inside the `fields` array, so the query loses the possibility of being type-safe. ```ts title="MikroORM" const author = await em.findOne( Author, {}, { fields: ['name', 'books.title', 'books.author', 'books.price'], }, ); ``` **3. Lack of Multi-level operators:** What if you need to filter the records of a relation or sort them? This seems unachievable in a type-safe way from what can be seen in the MikroORM docs. **4. Lack of consistent API across databases:** ```ts title="MikroORM" import { EntityManager } from '@mikro-orm/mongodb'; const em = orm.em as EntityManager; const qb = em.aggregate(/* ... */); ``` MikroORM exposes low-level details about MongoDB that could be encapsulated by the ORM. --- ## Why is UQL the closest to a perfect ORM? In short, because it was designed from the beginning with all of the above foundations. Let’s see how. ### 1. 100% serializable queries Even the insert and update operations have a fully serializable API. ```ts const lastUsers = await pool.findMany(User, { $select: { id: true, name: true, email: true }, $sort: { createdAt: -1 }, $limit: 20, }); ``` ### 2. Native TypeScript with truly type-safe queries Every operator and field is validated according to the context. For example, the possible values for the `$select` operator will automatically depend on the level. ```ts const lastUsersWithProfiles = await pool.findMany(User, { $select: { id: true, name: true }, $populate: { profile: { $select: { id: true, picture: true }, $required: true }, }, $sort: { createdAt: -1 }, $limit: 20, }); ``` ### 3. Multi-level operators The operators work on any level. For example, `$sort` and `$where` can be applied to the relations and their fields in a type-safe and context-aware way. ```ts const items = await pool.findMany(Item, { $select: { id: true, name: true }, $populate: { measureUnit: { $select: { id: true, name: true }, $where: { name: { $ne: 'unidad' } }, $required: true, }, tax: { $select: { id: true, name: true } }, }, $where: { price: { $gte: 1000 }, name: { $istartsWith: 'A' }, }, $sort: { tax: { name: 1 }, measureUnit: { name: 1 }, createdAt: -1, }, $limit: 100, }); ``` ### 4. Consistent API across databases One API for every database: the same entities and queries transparently work on any supported database, with the dialect-specific SQL (or MongoDB commands) generated under the hood. This makes it easier to switch from a Document to a Relational database (or vice-versa); the [migration guide](https://uql-orm.dev/switching-to-uql.md) walks through what that move looks like coming from Mongoose. ### 5. Universal syntax across languages Its syntax is standard JSON, so queries can be produced or consumed from other languages, enabling interoperation or even implementations of UQL in languages such as Python or Rust. --- See more at **[uql-orm.dev](https://uql-orm.dev)**. Please star it ⭐ on **[GitHub](https://github.com/rogerpadilla/uql)** if you like the idea! --- --- *[UQL](https://uql-orm.dev/index.md) is a JSON-native TypeScript ORM for Node.js, Bun and Deno. Supports PostgreSQL, PGlite, MySQL, MariaDB, MSSQL, SQLite, CockroachDB, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.* # In search of the type-safest ORM > Reading a column your query never selected is undefined at runtime and silent at compile time. I resisted fixing it for a while, because narrowing a row takes something away. Then I checked what my own projects actually do. Source: https://uql-orm.dev/blog/in-search-of-the-type-safest-orm The [type-safety benchmark](https://github.com/rogerpadilla/ts-orm-benchmark#type-safety) writes ten ordinary mistakes in six ORMs’ own APIs and compiles them, so “type-safe” stops being a word on a homepage and becomes a count. UQL caught nine. This is the story of the tenth, and of why I refused to fix it for a while. ## The mistake ```ts const [user] = await pool.findMany(User, { $select: { id: true, name: true }, }); user.email; // undefined at runtime. Every time. ``` The `email` column was never selected, so the property is not there. Nothing objected, because the result was typed as the whole entity no matter what the query asked for. The `undefined` travels into a response body or the next update, and surfaces days later as a null column nobody wrote. ## Why I left it alone (temporarily) I knew how to fix it. At first I didn’t want to, and the reason wasn’t the type machinery. Narrowing takes something away. Once a row is `{ id, name }` instead of `User`, it stops being the loose bag application code likes to pass around: hand it to a helper typed `(user: User) => ...` and it’s rejected. I wasn’t keen to trade a daily convenience for a compile error on a mistake I rarely make. So I built it, upgraded every project I maintain, the professional ones included, and counted the damage. There was none: [Variability](https://variability.ai) alone has 200+ find calls across 25+ files, and it compiled clean, unchanged. The pattern I was protecting turned out to be one I don’t actually write. Code that tops up a row starts from a full entity, because it needs the fields; code that projects ships the row straight out, to an API response, a list, a picker. --- ## Why UQL still catches the typo To shape the row, the query has to be captured as a type parameter, and that is where the ground moved. TypeScript 6.0 stopped reporting unknown keys on a literal checked against a captured map. Prisma’s and Drizzle’s projections are exactly that shape, which is why a misspelled column [compiles for both today](https://uql-orm.dev/type-safety.md), and why they sit at nine and eight rather than ten. UQL captures the field *names* instead of the map that holds them, so the compiler is asked a different question: ```ts $select: { id: true, emial: true } // against a captured map: is 'emial' a known key? - no longer reported // against captured names: 'id' | 'emial' extends FieldKey? - 'emial' is not a field ``` The second is a constraint, and constraints never went anywhere. `$where`, `$sort` and each populated relation aren’t captured at all, so they keep the checks they always had. --- ## What does it look like now? The row is what you asked for, and the compiler is aware: ```ts const [user] = await pool.findMany(User, { $select: { name: true }, $populate: { posts: { $select: { id: true, title: true } } }, }); user.name; // string user.posts.map((post) => post.title); // a list, empty at worst, no guard needed user.email; // compile error: not selected user.profile; // compile error: not populated ``` A query with no projection still returns the whole entity, so nothing changes where you didn’t ask for anything. And where a helper genuinely has to take a projected row, name the shape rather than widening the query: ```ts import type { QueryFindResult } from 'uql-orm'; type UserCard = QueryFindResult; ``` That is ten of ten on the [benchmark](https://uql-orm.dev/type-safety.md), the only entry with no red mark. [![](/type-safety-poster.webp)](/type-safety.mp4) Each ORM's ten mistakes, underlined by the compiler as the tab changes. [Type it yourself](https://uql-orm.dev/type-safety.md). Ten probes can’t separate six ORMs the way a microsecond can, and I chose the ten. So the claim is the narrow one: these are ten mistakes people make on an ordinary afternoon, and this is the ORM that refuses all of them. Every probe is in [type-safety/](https://github.com/rogerpadilla/ts-orm-benchmark/tree/main/type-safety), compiled as written and again with each mistake corrected. Clone it and check. And if you’re where I was, weighing a check you suspect you won’t need against a convenience you’re sure you use: upgrade a branch and count the damage. It’s a cheaper argument than the one I had with myself. --- --- *[UQL](https://uql-orm.dev/index.md) is a JSON-native TypeScript ORM for Node.js, Bun and Deno. Supports PostgreSQL, PGlite, MySQL, MariaDB, MSSQL, SQLite, CockroachDB, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.* # How much memory does an ORM cost per request? > Weighing the heap around a query looked like an afternoon's work. I got three plausible wrong answers first: zero bytes on Bun, a 19% skew from forcing a collection, and a ranking that turned out to be the running order. Source: https://uql-orm.dev/blog/measuring-orm-memory Someone on r/bun [asked how UQL’s RAM usage compares to Drizzle’s](https://www.reddit.com/r/bun/comments/1w2s434/comment/p6vlefo/). I didn’t have an answer, so I measured it: weigh the heap, run the query, weigh it again, subtract. ## Three wrong numbers at the beginning **Bun reported zero.** JavaScriptCore refreshes `heapUsed` only when it collects, so every entry looked like it had allocated nothing. The memory run is on Node, minus the two Bun SQL entries. **A forced `global.gc()` added 19%.** It frees compiled code along with the garbage, and the rounds after it re-optimise as they run, allocating extra. **One process ranked them by start order.** The first entry paid to JIT what the rest inherited warm. One process per entry now. ## What came out Against hand-written `raw pg`, rows mapped by hand, so what you see is the ORM’s own allocation. | Entry | Total KB | Adds KB | | - | - | - | | raw pg | 245 | floor | | **UQL** | 446 | **+201** | | Drizzle | 769 | +524 | | Prisma | 1,053 | +808 | | TypeORM | 1,065 | +820 | | Sequelize | 1,296 | +1,051 | | MikroORM | 3,864 | +3,619 | 18x between lightest and heaviest, where the same lifecycle in time spans 10x. One step causes nearly all of it: the nested read, the only one that turns two result sets into an object graph, costs UQL 184KB and MikroORM 2,060KB. ## What survives Sixty more lifecycles, collected either side, leave the worst entry 42KB heavier (Sequelize), identity maps and all. What the table prices is the garbage each request hands the collector, not memory that stays. ## The answer UQL adds 201KB per request, Drizzle 524KB, MikroORM 3.6MB. That is allocation, not resident memory, on one machine with no pooling and no concurrency. [Run it yourself](https://github.com/rogerpadilla/ts-orm-benchmark): `bun run bench.memory` rewrites the table it publishes, so the [current numbers](https://uql-orm.dev/benchmark.md#memory) can never drift from the last run. --- --- *[UQL](https://uql-orm.dev/index.md) is a JSON-native TypeScript ORM for Node.js, Bun and Deno. Supports PostgreSQL, PGlite, MySQL, MariaDB, MSSQL, SQLite, CockroachDB, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.* # Semantic search: native vector similarity in a multi-dialect ORM > UQL 0.3 brings native vector similarity search to PostgreSQL, MariaDB, SQLite, and MongoDB Atlas through one type-safe query API, including automatic index migration for AI and RAG workloads. Source: https://uql-orm.dev/blog/semantic-search Most ORMs stop short of vector similarity. The moment you need it, you drop to raw SQL, hand-writing distance expressions and working around dialect quirks outside your type-safe query API. UQL 0.3 adds native semantic search to the regular query API, including automatic index migration for HNSW and IVFFlat indexes. > **Updated September 2026** > > Vector search has since reached CockroachDB, libSQL, Turso and SQL Server 2025, and gained [`$near`](https://uql-orm.dev/querying/semantic-search.md#distance-predicate) to filter by distance and [`$candidates`](https://uql-orm.dev/querying/semantic-search.md#tuning-recall) to tune recall. The [reference](https://uql-orm.dev/querying/semantic-search.md) has the current per-engine detail. ## What it looks like ```ts title="You write" const queryVec = await embed('How do vector indexes work?'); const results = await pool.findMany(Article, { $select: { id: true, title: true }, $sort: { embedding: { $vector: queryVec, $distance: 'cosine' } }, $limit: 10, }); ``` UQL generates the right SQL for your database: PostgreSQL: ```sql SELECT "id", "title" FROM "Article" ORDER BY "embedding" <=> $1::vector LIMIT 10 ``` MariaDB: ```sql SELECT `id`, `title` FROM `Article` ORDER BY VEC_DISTANCE_COSINE(`embedding`, VEC_FromText(?)) LIMIT 10 ``` SQLite: ```sql SELECT `id`, `title` FROM `Article` ORDER BY vec_distance_cosine(`embedding`, ?) LIMIT 10 ``` The same query works on every dialect, with no raw SQL or dialect checks in your code. For MongoDB, UQL translates the same query into an Atlas `$vectorSearch` pipeline. The Atlas search index itself has to be created in Atlas; see the [reference](https://uql-orm.dev/querying/semantic-search.md) for details. MongoDB Atlas: ```json [ { "$vectorSearch": { "index": "embedding_index", "path": "embedding", "queryVector": ["..."], "numCandidates": 100, "limit": 10 } } ] ``` ## Entity setup Define your vector field and index; UQL handles schema generation, extension creation, and index building: ```ts import { Entity, Id, Field, Index } from 'uql-orm'; @Entity() @Index((article) => [article.embedding], { type: 'hnsw', distance: 'cosine', m: 16, efConstruction: 64, }) export class Article { @Id({ type: Number }) id?: number; @Field({ type: String }) title?: string | null; @Field({ type: 'vector', dimensions: 1536 }) embedding?: number[] | null; } ``` For Postgres, UQL automatically emits `CREATE EXTENSION IF NOT EXISTS vector`. MariaDB has vector support built in; SQLite requires loading the [sqlite-vec](https://github.com/asg017/sqlite-vec) extension. ## What else shipped `$project` returns the computed distance as a named field, typed with the exported `WithDistance` helper, without computing it twice. Alongside it came four distance metrics (`cosine`, `l2`, `inner`, `l1`), three vector types (`vector`, `halfvec`, `sparsevec`), and HNSW, IVFFlat and native vector indexes, each where the engine has it. Which engine has which lives in the [reference](https://uql-orm.dev/querying/semantic-search.md), kept current as engines are added. ## Why `$sort`? Vector similarity search is fundamentally sorting by distance. UQL reuses the existing `$sort` API, which composes naturally with `$where`, `$select`, `$limit`, and regular sort fields: ```ts const results = await pool.findMany(Article, { $where: { category: 'science' }, $sort: { embedding: { $vector: queryVec, $distance: 'cosine' }, title: 'asc', }, $limit: 10, }); ``` ## Get started ```bash npm i uql-orm ``` - **[Full documentation](https://uql-orm.dev/querying/semantic-search.md)** - **[Comparison](https://uql-orm.dev/comparison.md)** - **[GitHub](https://github.com/rogerpadilla/uql)** If you run into issues or missing features, open an issue on [GitHub](https://github.com/rogerpadilla/uql). --- --- *[UQL](https://uql-orm.dev/index.md) is a JSON-native TypeScript ORM for Node.js, Bun and Deno. Supports PostgreSQL, PGlite, MySQL, MariaDB, MSSQL, SQLite, CockroachDB, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.* # Standard decorators: props & cons > uql-orm 0.23 moved from legacy TypeScript decorators to the TC39 standard spec. No compiler flags, no reflect-metadata, and a type check that legacy decorators structurally could not do. The cost: three decorators deleted, NestJS locked out, and type on every field. Source: https://uql-orm.dev/blog/standard-decorators UQL 0.23 moved its decorators to the [TC39 standard spec](https://github.com/tc39/proposal-decorators). `experimentalDecorators` and `emitDecoratorMetadata` are gone, `reflect-metadata` is gone, and one thing became possible that was not possible before. It also deleted three decorators, removed parameter injection, and locked NestJS out of the decorator API entirely. Migration posts usually stop at the wins. This is the whole ledger. ## What it looks like ```ts title="Before: legacy decorators" import { Entity, Field, Id, ManyToOne } from 'uql-orm'; import 'reflect-metadata'; @Entity() class User { @Id() id?: number; @Field() name?: string; @ManyToOne() company?: Relation; } ``` ```ts title="After: the standard spec" @Entity() class User { @Id({ type: Number }) id?: number; @Field({ type: String }) name?: string | null; @Field({ references: () => Company }) companyId?: number | null; @ManyToOne({ entity: () => Company, references: (user) => user.companyId }) company?: Company; } ``` More typing. `type` on every field, `entity` on every relation, because nothing reflects any more. That looks like a pure loss until you notice what the compiler can now do with it. ## The declared type is checked against the property This is the part worth the migration. Under the legacy spec, a property decorator has this shape: ```ts type PropertyDecorator = (target: Object, propertyKey: string | symbol) => void; ``` There is no type parameter carrying the property’s type. The decorator is handed a key and nothing else, so the options you pass it are just data. Write this and it compiled fine: ```ts @Field({ type: String }) age?: number; ``` You got a `TEXT` column for a number, and you found out when the data looked wrong. `emitDecoratorMetadata` did not save you, because an explicit `type` skipped inference entirely: ```ts title="packages/uql-orm/src/entity/metadata/definition.ts (0.22)" if (opts.type) { opts = { ...opts, typeInferred: false }; } else { opts = { ...opts, type: inferType(entity, key), typeInferred: true }; } ``` Reflection was the fallback, not the check. The moment you stated a type, the one thing that knew the real type stopped looking. The standard spec hands a field decorator a `ClassFieldDecoratorContext`, which is generic in the field’s value type. That single difference is the whole story, because now a decorator can constrain what it may be attached to: ```ts title="packages/uql-orm/src/entity/decorator/members.ts" /** A member decorator that also constrains the property it may be applied to. */ type MemberDecorator = ( value: undefined, context: ClassFieldDecoratorContext, ) => void; /** A declared `type` wins; otherwise the column is the referenced primary key's own type. */ type DeclaredValue = O extends { readonly type: infer T extends FieldType } ? TsTypeOf : O extends { readonly references: EntityGetter } ? IdValue : never; export function Field< O extends FieldOptions> & ({ type: FieldType } | { references: EntityGetter }), >(opts: O): MemberDecorator | undefined>; ``` `@Field({ type: String })` returns a decorator that only applies to `string | undefined`. Put it on a `number` and it does not compile. The mandatory `type` stopped being redundant typing and became a claim the compiler checks. Once one option can name the value a property holds, the others have to agree with it: ```ts import { OneToMany } from 'uql-orm'; @ManyToOne({ entity: () => Company, references: (user) => user.companyId }) company?: number; // error: a Company relation holds a Company, not its key @OneToMany({ entity: () => User, mappedBy: (user) => user.company }) users?: User; // error: a to-many cardinality needs an array @Field({ type: 'int' }) createdAt?: Date; // error: a Date field is not an integer column @Field({ references: () => User }) authorId?: string; // error: User's key is a number, so this column is one too @Id({ type: 'uuid', onInsert: () => 42 }) id?: string; // error: a uuid column is not stamped with a number ``` None of these were catchable before. They are not new bugs the migration introduced, they are old bugs it made visible. A type-test suite pins every one of them and fails the build if any stops erroring, and all but the foreign key survive being reached through the imperative `defineEntity` too. The last two arrived later, in 0.24.3. Same mechanism, applied to the two other things that decide what a column holds: the key a foreign key points at, and the generator that stamps a value into it. ## What reflection was costing `reflect-metadata` was 264 KB, carried for one call: `Reflect.getMetadata('design:type', ...)`. It also required every consumer to import it once, globally, before any entity loaded, and to remember to keep two compiler flags on. It bought less than it looked like. Reflected types could not survive a circular import, which is why `Relation` existed at all: an alias whose only job was to break a cycle that reflection itself created. It is deleted now. Dropping it is part of why the package [installs 1 MB with no dependencies](https://uql-orm.dev/blog/zero-dependencies.md). ## What we gave up The honest column. **`@InjectQuerier()` is gone.** The standard spec has no parameter decorators, and the [TC39 proposal for them](https://github.com/tc39/proposal-class-method-parameter-decorators) is still Stage 1. A `@Transactional()` method read its querier from `AsyncLocalStorage` instead, through `currentQuerier()`. **`@Transactional()` and `currentQuerier()` are gone too**, since 0.29.0. Nothing but the decorator ever published that ambient querier, so `currentQuerier()` threw inside `pool.transaction()` and every other entry point. A transaction is [`pool.transaction(async (querier) => ...)`](https://uql-orm.dev/querying/transactions.md), which needs no class, no decorator and no async-local storage. **`@Log()` and `@Serialized()` are gone.** A standard-spec decorator cannot preserve a generic method’s signature, so both would have quietly widened the types of anything they wrapped. The useful half of `@Log()`, error enrichment, was moved into the querier itself. **NestJS projects cannot use UQL’s decorators at all.** Nest injects constructor parameters with a parameter decorator, so a Nest project keeps `experimentalDecorators: true`, and one `tsconfig.json` cannot mix specs. This is not a temporary gap that closes when a proposal advances a stage. Those projects use [`defineEntity`](https://uql-orm.dev/entities/imperative.md) instead, which carries the same checks, bar the foreign key one. **`target: 'esnext'` is now forbidden.** It is the one target where TypeScript emits decorator syntax untransformed, which Node and every browser reject with a `SyntaxError`. Every dated target downlevels it correctly. **`declare` fields stop working.** The spec has nothing to decorate on a `declare` member, so narrowing an inherited relation needs a real field with an initializer. **Oxc, Vite 8’s default transformer, implements no decorators at all.** It preserves `@Entity()` verbatim at every target, so a Vite 8 project needs esbuild, SWC or Babel through a plugin. esbuild, SWC, Babel with `version: '2023-11'`, Bun and `tsc` all handle the spec. Node 24 is the new minimum, shipped in the same release. ## The two things that nearly broke it Neither is in the spec documents, and both cost real time. **`Symbol.metadata` does not exist yet.** No runtime we support defines it, checked on Node 24 and Bun 1.3. TypeScript’s decorator emit reads it to decide whether to build the metadata object at all, so without a polyfill every `context.metadata` is `undefined` and field registration is silently dropped rather than failing. UQL defines it with `Symbol.for`, not `Symbol()`, so a duplicated copy of the module under HMR or dual-loading lands on the same symbol, and so it agrees with the key esbuild and SWC fall back to. **tsc and SWC disagree about inheritance.** tsc chains a subclass’s `context.metadata` to its parent’s. SWC does not, in any decorator version. Anything built on that prototype chain works under one compiler and quietly loses inherited fields under the other. UQL resolves inheritance by walking the class prototype chain instead, and there is a test that constructs the unchained shape SWC emits to prove it. ## The codemod `uql-codemod` does most of the mechanical work, reading types from the real type checker rather than guessing from the parse tree: ```sh npx uql-codemod --dry-run npx uql-codemod ``` It rewrites `@Field`/`@Id` types, relation `entity` getters and `Relation` to `T`, and strips both flags from `tsconfig.json` while preserving your comments and formatting. It refuses to touch what it cannot be sure about: `target: esnext`, values inherited through `extends`, every removed decorator, options objects it cannot read, and branded string ids. `tsc` is the rest of the migration, and that is the point. The annotations the codemod inserts are checked against the properties they describe, so anything it got wrong is a compile error rather than a silently wrong column. ## Was it worth it Yes, and not because of the compiler flags. Removing two tsconfig settings and a 264 KB polyfill is worth something, but it is housekeeping. The reason to do this was that reflection put the type in two places and let them drift, and no amount of care fixes a design where the compiler cannot see the mistake. Now there is one declaration and the compiler checks it against the property. The bill was real: three decorators, parameter injection, and a framework’s worth of users pushed onto a different API. If you are on NestJS, this release cost you something and handed back a smaller install. That is a worse trade than everyone else got, and not one I can improve until TC39 moves. ## Get started ```sh npm i uql-orm npx uql-codemod --dry-run ``` - **[Upgrade guide](https://uql-orm.dev/upgrade-guide.md)** - **[Entities: decorators](https://uql-orm.dev/entities/basic.md)** and **[the imperative API](https://uql-orm.dev/entities/imperative.md)** - **[Zero dependencies: what we deleted to fit on the edge](https://uql-orm.dev/blog/zero-dependencies.md)** - **[Comparison](https://uql-orm.dev/comparison.md)** - **[GitHub](https://github.com/rogerpadilla/uql)** If the codemod leaves something behind that it could have handled, open an issue with the entity that tripped it. --- --- *[UQL](https://uql-orm.dev/index.md) is a JSON-native TypeScript ORM for Node.js, Bun and Deno. Supports PostgreSQL, PGlite, MySQL, MariaDB, MSSQL, SQLite, CockroachDB, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.* # What does an ORM really cost you? > A full PostgreSQL round trip through six ORMs: insert, read, update, read, nested read, delete, read. What each one adds over hand-written driver code, where Prisma loses two thirds of a millisecond on one step, and why totals hide all of it. Source: https://uql-orm.dev/blog/what-orms-really-cost My bench now does real stuff, full PostgreSQL round-trip operations. Six ORMs, one real PostgreSQL, and a full lifecycle per pass: insert 10 rows, read 200 back with a filter and a sort, update one, read it again, load 50 parents with their children, delete, read the empty table. Every step timed separately, median of 250 interleaved iterations. ## The results | Entry | Adds | Total | | - | - | - | | UQL | +232µs | 1,621µs | | Drizzle | +685µs | 2,074µs | | TypeORM | +838µs | 2,227µs | | Sequelize | +1,185µs | 2,574µs | | Prisma | +1,345µs | 2,734µs | | MikroORM | +2,236µs | 3,625µs | > Figures as measured when this was written, on `pg` throughout. The harness is re-run as versions move, so the [current results](https://uql-orm.dev/benchmark.md) will differ. **Adds** is the number that matters, and it is the whole reason this benchmark works. Every entry pays the same PostgreSQL bill, so the totals compress into a 2.2x range and every ORM looks about the same. Subtract the floor, which is hand-written SQL with the rows mapped by hand at 1,389µs, and what is left is the ORM’s own contribution. That spans nearly 9.6x. Everything above runs on node-postgres, so the driver is not the variable. Four of the six support nothing else. ## Prisma Prisma is not slow across the board, and it does not finish last; it was MikroORM at 3,625µs against Prisma’s 2,734µs. Prisma’s nested read, the hardest step in the set, is the second fastest of any ORM here at 440µs. Its update, delete and single-row reads are all mid-field. It is one step: | INSERT 10 rows | | | - | - | | raw pg (hand-written) | 454µs | | UQL | 488µs | | MikroORM | 625µs | | Drizzle | 635µs | | Sequelize | 643µs | | TypeORM | 668µs | | **Prisma** | **1,366µs** | That one step is 2.0x the next slowest ORM and it is most of why Prisma places fifth. Turning on its query log shows what it sends: ```sql title="What Prisma sends for createManyAndReturn" INSERT INTO "public"."User" ("name","email","companyId") VALUES ($1,$2,$3), ($4,$5,$6), ... COMMIT ``` So `createManyAndReturn` wraps the batch in an explicit transaction, where the other five send one statement and stop. That is a fair thing to do and it is not the explanation: a BEGIN/COMMIT pair on this machine costs 88µs, and the gap to TypeORM, the next slowest here, is 698µs. The transaction is about an eighth of it. The rest is Prisma’s own overhead on the way in and out, which is the part you cannot opt out of. Prisma 7 dropped its Rust query engine, and the client runtime is now TypeScript. This is the faster Prisma, not the old one. ## The nested read is where ORMs are actually decided It is the only step that loads a relation, and it has the widest spread of any read: | SELECT 50 parents with their children | | | - | - | | raw pg (hand-written) | 256µs | | UQL | 346µs | | Prisma | 440µs | | TypeORM | 498µs | | Drizzle | 535µs | | Sequelize | 680µs | | MikroORM | 1,205µs | This is the step worth caring about, because it is the one an ORM exists to do. Anyone can send an `INSERT`. Turning two result sets into an object graph without an N+1 is the actual job, and the field spans 3.5x on it. ## Run it yourself ```bash git clone https://github.com/rogerpadilla/ts-orm-benchmark.git cd ts-orm-benchmark bun install DATABASE_URL=postgres:///postgres npm run bench ``` It creates its own database and rewrites its own result tables, so the published numbers cannot drift from the last run. CI runs the full lifecycle with assertions on every push. The latest set always lives on the [benchmark page](https://uql-orm.dev/benchmark.md), and there is a feature-by-feature [comparison](https://uql-orm.dev/comparison.md) if speed is not your only axis. If your numbers come out different, open an issue. --- --- *[UQL](https://uql-orm.dev/index.md) is a JSON-native TypeScript ORM for Node.js, Bun and Deno. Supports PostgreSQL, PGlite, MySQL, MariaDB, MSSQL, SQLite, CockroachDB, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.* # Zero dependencies: what we deleted > uql-orm installs one package, 288 kB on the wire, no runtime dependencies, every dialect included. Here is what came out to get there: reflect-metadata, jiti, tslib, sqlstring, 369 sourcemaps, and the changelog. Source: https://uql-orm.dev/blog/zero-dependencies `npm i uql-orm` installs one package. No dependencies, every dialect included. A month ago it had four mandatory packages, roughly 4 MBs unpacked; now it is 992 KB unpacked. ## One release, four dependencies gone `0.21.0` did it in one pass: \~4.0 MB down to 992 KB, cold start from 16.9 ms to 8.9 ms. Four packages, one job each, none worth a dependency of its own. `tslib` existed so TypeScript’s helper functions didn’t repeat across files. `"importHelpers": false` costs a few hundred bytes per file instead of a dependency edge. `sqlstring` escaped MySQL and MariaDB literals, badly: a `Uint8Array` came out as `` `0` = 255 `` instead of `X'ff00'`, a plain object as `'[object Object]'`. No crash, no warning, just wrong output that looks fine until it isn’t. `Dialect.escape` handles all three SQL dialects itself now, byte-for-byte identical across 29 value shapes, and those two cases throw instead of writing garbage. `reflect-metadata` (264 KB) earned its keep with one call, `Reflect.getMetadata('design:type', ...)`, so `@Field()` could guess a column’s type from the property. Optional as of this release, gone two releases later. `jiti` (1.8 MB), a full TypeScript transpiler, existed to read one file: `uql.config.ts`. That’s a lot of compiler to hire for one sticky note. Also gone; that config now needs a runtime that already speaks TypeScript (`bun`, or `node --import tsx`). Then the stuff that was never code: 369 sourcemaps nobody opened, and a 108 KB CHANGELOG, shipped in every install anyway. `files` in `package.json` is `["dist", "README.md"]` now. ## Then reflection went too Two releases later, `reflect-metadata` stopped being optional and started being pointless. `0.23.0` moved decorators to the [TC39 standard spec](https://github.com/tc39/proposal-decorators): `@Field()` and `@Id()` require an explicit `type` now, checked against the property, so there’s nothing left to reflect. The full story, including what it cost, is in [Standard decorators: props & cons](https://uql-orm.dev/blog/standard-decorators.md). ## What one `npm i` puts on disk Install one package into an empty project and count the bytes yourself: ```sh npm i --omit=dev uql-orm find node_modules -type f -exec cat {} + | wc -c ``` Unpacked bytes on disk, not the 288 kB you download. Tarballs compress; cold starts don’t care. | Package | Installed | Files | | - | - | - | | `uql-orm` 0.24.1 | 1.0 MB | 384 | | `@mikro-orm/postgresql` 7.1.9 | 4.7 MB | 1,153 | | `drizzle-orm` 0.45.2 | 9.9 MB | 2,667 | | `sequelize` 6.37.8 | 15.0 MB | 2,708 | | `typeorm` 1.1.0 | 22.5 MB | 3,663 | | `@prisma/client` 7.9.1 | 75.0 MB | 94 | One thing to hold against this table before you screenshot it: 93% of `@prisma/client` is 70 MB of Rust query compilers cross-compiled to WebAssembly, one per engine it supports, which is either an engineering marvel or a cry for help. UQL ships every dialect too, so that part isn’t a fair hit on Prisma. Per dialect it’s actually smaller: 4.9 MB for its Postgres compiler alone against 19.9 kB gzipped for UQL’s entire Postgres entry point. No row includes a driver. Add `pg` to any of them and they all grow the same amount. This table is the floor. ## What the small number doesn’t cover UQL is the small number here, your driver isn’t. `pg`, `mysql2`, `mariadb`, `mongodb` and `better-sqlite3` all have their own weight, and the last one needs a native build. UQL’s promise is narrower than “small”: it adds nothing on top of whichever one you already picked. Every entry point has a gzip budget checked on every build, because 0.13.0 once let `node:async_hooks` leak into browser bundles and had to be pulled. Lesson learned, budget added. ## Get started ```sh npm i uql-orm ``` - **[Quick Start](https://uql-orm.dev/getting-started.md)** - **[Standard decorators: props & cons](https://uql-orm.dev/blog/standard-decorators.md)** - **[Comparison](https://uql-orm.dev/comparison.md)** - **[GitHub](https://github.com/rogerpadilla/uql)** If your install contains something that shouldn’t be there, open an issue. --- --- *[UQL](https://uql-orm.dev/index.md) is a JSON-native TypeScript ORM for Node.js, Bun and Deno. Supports PostgreSQL, PGlite, MySQL, MariaDB, MSSQL, SQLite, CockroachDB, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.*