Skip to content
NewFrom Mongoose to SQL6 min read

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.

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'.

  • Date is stored in UTC to the millisecond on every engine.
  • Number is a BIGINT. Past 2^53 it reads back as exact text, never a rounded number, except on D1, whose API returns a JS number. Use BigInt for an exact typed integer.
  • A decimal reads and writes as exact text (price?: string | null). type: Number with precision and scale gives 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, $select and $sort take 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;

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
currentTimestamp the clock, UTC to the millisecond everywhere
currentDate today everywhere
currentTime 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.

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; // nullable

The codemod adds | null to existing entities.

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 missing
const [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 filled
const [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.

@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.

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;
}

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.

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.

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.

The table takes the class name. @Entity({ name }) overrides it, and @Entity({ schema }) sets its schema.

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 and @Id take these, besides type:

Option What it does
columnType Exact SQL type, or a sql constant.
length String length. Unset: TEXT (Postgres, SQLite) or VARCHAR(255) (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>__<column>_idx.
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.
defaultValue A value, or per-row SQL such as currentTimestamp. 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.
onDelete The foreign key’s ON DELETE action. A relation’s own wins.
onInsert, onUpdate Value for a new row (uuidv7), or on every update (() => new Date()).
updatable false leaves the field out of updates, e.g. createdAt.
eager false leaves the field out of reads unless selected, e.g. password.
autoIncrement @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.
softDelete 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.