Decorators
An entity is a plain TypeScript class that maps to a table. @Entity marks the class, @Id the primary key and @Field each column:
import { Entity, Id, Field } from 'uql-orm';import { v7 as uuidv7 } from 'uuid';
@Entity()export class User { @Id({ type: 'uuid', onInsert: uuidv7 }) id!: string;
@Field({ type: String, nullable: false }) name!: string;
@Field({ type: String, unique: true }) email?: string | null;}These are standard TC39 decorators: leave experimentalDecorators off. To define entities without decorators, use the imperative API.
Column types
Section titled “Column types”Every field states a type, and it must match the property: @Field({ type: String }) on a number does not compile. Each dialect maps it to its own SQL type: 'uuid' is UUID on Postgres and CHAR(36) on MySQL.
It takes String, Number, Boolean, BigInt, Date, or a SQL type name such as 'uuid', 'text', 'decimal', 'jsonb' or 'vector'.
Dateis stored in UTC to the millisecond on every engine.Numberis aBIGINT. Past 2^53 it reads back as exact text, never a rounded number, except on D1, whose API returns a JS number. UseBigIntfor an exact typed integer.- A
decimalreads and writes as exact text (price?: string | null).type: Numberwithprecisionandscalegives a JS number instead, which rounds past 15 digits. SQLite stores a float either way. - A JSON property typed
Json<T>counts as a field, so$where,$selectand$sorttake its dot-notation paths.
import type { Json } from 'uql-orm';
@Field({ type: 'decimal', precision: 12, scale: 2 })price?: string | null;
@Field({ type: 'jsonb' })metadata?: Json<{ theme?: string; priority?: number }> | null;columnType sets the exact SQL type. For a type uql does not map (tsvector, ltree, inet, citext, geometry), pass a sql constant (never a bare string). It is emitted verbatim, so length, precision, scale and dimensions are rejected beside it:
import { sql } from 'uql-orm';
@Field({ type: String, columnType: 'varchar', length: 500 })longBio?: string | null;
@Field({ type: String, columnType: sql`tsvector`, computed: (post) => sql`to_tsvector('simple', ${post.body})`, stored: true,})readonly searchVector?: string | null;Values the database computes
Section titled “Values the database computes”These constants are SQL the database evaluates. Use them in a defaultValue, onUpdate, a stamp or $where, and in migrations:
| Value | What it is | Where |
|---|---|---|
current |
the clock, UTC to the millisecond | everywhere |
current |
today | everywhere |
current |
the time of day | everywhere |
uuid |
a UUID: version 4, or version 1 on MySQL and MariaDB | not SQLite |
uuidv7 |
a time-ordered version 7 UUID, which indexes better | Postgres 18+, MariaDB 11.7+ |
import { currentTimestamp } from 'uql-orm';
@Field({ type: Date, defaultValue: currentTimestamp })createdAt?: Date | null;Prefer currentTimestamp to a raw CURRENT_TIMESTAMP, which is not UTC to the millisecond on MySQL, SQL Server or SQLite. An engine lacking one of these throws; write sql there.
Nullable columns
Section titled “Nullable columns”A column is nullable unless it declares nullable: false, and a nullable column’s property must include null. A mismatch does not compile:
@Field({ type: String, nullable: false }) title!: string; // NOT NULL@Field({ type: Date }) publishedAt?: Date | null; // nullableThe codemod adds | null to existing entities.
Required fields
Section titled “Required fields”Declare a nullable: false property with !, like name above. Inserts must include it, and a read that selects it always has it:
await pool.insertOne(User, { name: 'Ada' }); // `{}` does not compile: `name` is missingconst [user] = await pool.findMany(User, { $select: { id: true, name: true } });user.name.trim(); // a `string`, never `undefined`An insert may still omit a ! single-column key and a version column, which uql fills, and the fields the filledKey brand lists: each nullable: false field a default fills, such as a defaultValue or an onInsert. A read still has them:
import { currentTimestamp, filledKey } from 'uql-orm';
@Entity()export class Product { [filledKey]?: 'stock' | 'createdAt';
@Id({ type: Number }) id!: number; @Field({ type: String, nullable: false }) name!: string; @Field({ type: Number, nullable: false, defaultValue: 0 }) stock!: number; @Field({ type: Date, nullable: false, defaultValue: currentTimestamp }) createdAt!: Date;}
await pool.insertOne(Product, { name: 'Lamp' }); // `stock` and `createdAt` are filledconst [product] = await pool.findMany(Product, {});product.stock.toFixed(); // a `number`, never `undefined`Declare everything else an insert may omit with ?: nullable columns, eager: false and computed fields, and relations. A composite key must be given in full.
Primary keys
Section titled “Primary keys”@Id takes the same options as @Field, and a key is never null. @Id({ type: Number }) lets the database generate an integer, which keeps indexes small and joins fast. @Id({ type: 'uuid', onInsert: uuidv7 }) generates a UUID in your code before the row exists, and cannot be guessed from a public URL.
Naming the key
Section titled “Naming the key”A key named id, _id or uuid is recognized. Any other name needs the idKey brand, or findOneById would accept any column’s values:
import { idKey } from 'uql-orm';
@Entity()export class TaxCategory { [idKey]?: 'pk';
@Id({ type: String, onInsert: uuidv7 }) pk!: string;}Composite primary keys
Section titled “Composite primary keys”Several @Ids make one composite key, in declaration order. The brand names every key column, and every by-id call takes all of them:
@Entity()export class Enrolment { [idKey]?: 'studentId' | 'courseId';
@Id({ type: Number }) studentId?: number;
@Id({ type: String }) courseId?: string;
@Field({ type: String }) grade?: string | null;}
await pool.updateOneById( Enrolment, { studentId: 1, courseId: 'maths' }, { grade: 'A' },);The type cannot require every key, so an id missing one throws at run time instead of matching more rows.
Pointing at a composite key
Section titled “Pointing at a composite key”A foreign key to a composite key needs one column per key column, paired in the relation, and the pairs form one FOREIGN KEY. @Field({ references: () => Enrolment }) is refused. A junction pointing at a composite side needs this @ManyToOne too.
@ManyToOne({ entity: () => Enrolment, references: (note, enrolment) => [ { local: note.enrolmentStudentId, foreign: enrolment.studentId }, { local: note.enrolmentCourseId, foreign: enrolment.courseId }, ],})enrolment?: Enrolment;A cascade to-many or many-to-many over a composite side writes those columns too: each child, or junction row, carries the whole key of its parent and of its target.
What is not supported
Section titled “What is not supported”Reads, writes, cascading deletes and saving a relation all handle composite keys. Two paths throw on one: MongoDB, where a document has a single _id (use a unique compound index instead), and the HTTP /:id route, where a composite row is reachable through $where.
Table name
Section titled “Table name”The table takes the class name. @Entity({ name }) overrides it, and @Entity({ schema }) sets its schema.
If your build minifies, name the table
Section titled “If your build minifies, name the table”A minifier renames classes (User becomes b), and the table name would follow. Set @Entity({ name: 'user' }) in any build that minifies, Next’s server build included; drift:check catches a mismatch.
Field options
Section titled “Field options”@Field and @Id take these, besides type:
| Option | What it does |
|---|---|
column |
Exact SQL type, or a sql constant. |
length |
String length. Unset: TEXT (Postgres, SQLite) or VARCHAR (MySQL, MariaDB). |
precision, scale |
A decimal’s digits and digits after the point. precision on a Date sets fractional-second digits. |
dimensions |
A vector’s dimensions: { type: 'vector', dimensions: 1536 }. |
name, comment |
Column name (defaults to the property name) and its database comment. |
nullable |
Defaults to true. |
unique |
Unique index, named by index or <table>_. |
index |
true adds an index; a string names it. A foreign key is indexed unless this is false. |
enum |
Allowed values as a CHECK. Needs as const. |
default |
A value, or per-row SQL such as current. JSON columns take a document, [] or {}. |
references |
() => Entity makes a foreign key typed by the target’s key, so it takes no type. A relation over it loads the target. |
on |
The foreign key’s ON DELETE action. A relation’s own wins. |
on, on |
Value for a new row (uuidv7), or on every update (() => new Date()). |
updatable |
false leaves the field out of updates, e.g. created. |
eager |
false leaves the field out of reads unless selected, e.g. password. |
auto |
@Id only. Defaults to true for numeric keys. |
computed |
A database expression or relation aggregate. |
stored |
true makes a computed field a generated column; a list of events makes it a stamp. |
soft |
Marks the soft-delete field: true stamps new Date(), or give the value to stamp. |
version |
Makes the column an optimistic lock. |
distance |
Default vector metric: 'cosine', 'l2', 'inner' or 'l1'. Falls back to the vector index’s, then 'cosine'. |
An option that would have no effect does not compile, and defineEntity throws on it. length needs a string column, precision a numeric or date one, scale and autoIncrement a numeric one, dimensions and distance a vector. A computed field takes no defaultValue, onInsert or onUpdate, and unless stored, no index, unique or columnType. A relation aggregate takes neither type nor stored. onUpdate conflicts with updatable: false, and a key cannot be nullable: true.
Class-level decorators have their own pages: @Index, @Filter, @Trigger, and the relation decorators.