Decorators
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 TC39 decorators. Every field states its type, which is checked against the property it is declared on, so @Field({ type: String }) on a number is a compile error rather than a silently wrong column. If you prefer plain classes, the imperative API (defineEntity) registers identical metadata from the same options, and is checked the same way bar one foreign-key case.
Core Decorators
Section titled “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). |
@Id({ type }) |
Defines the primary key, with support for onInsert generators (UUIDs, etc). Declared twice, the key is composite. |
@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 (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. |
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;
@Field({ type: String, unique: true, comment: 'User login email', }) email?: string;
@Field({ type: 'text' }) bio?: string;}If your build minifies, name the table
Section titled “If your build minifies, name the table”Without the explicit name property, the table is the class’s own name, which identifier mangling rewrites: User bundles to b under one minifier and h under another, and moves again as the bundle’s contents change. Bun’s --keep-names does not currently preserve it. Pass @Entity({ name: 'user' }) on anything that ships minified. Everything else survives, since the metadata is keyed on the class itself and relations resolve through () => Entity getters; only this default is a string. drift:check catches it.
Type Abstraction
Section titled “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:
import type { Json } from 'uql-orm';
// Recommended: use `type` for semantic, cross-database types@Field({ type: 'uuid' })externalId?: string;
@Field({ type: 'jsonb' })metadata?: Json<{ theme?: string; priority?: number }>;
@Field({ type: 'text' })bio?: string;
// Use sparingly: `columnType` for precise SQL control. `type` is still required - it is// what the compiler checks the property against; `columnType` only picks the SQL type.@Field({ type: Number, columnType: 'decimal', precision: 10, scale: 2})price?: number;
@Field({ type: String, columnType: 'varchar', length: 500})longBio?: string;type: 'uuid' generates UUID on Postgres and CHAR(36) on MySQL, so the same entity migrates to either.
Wrapping a JSONB field’s type with Json<T> classifies it as a FieldKey rather than a RelationKey, which is what makes it usable in $where, $select and $sort, with autocompletion for dot-notation paths.
Field Options
Section titled “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. |
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'. |
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. true stamps the current timestamp (new Date()); a callback stamps its result (e.g., () => Date.now()). |
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 |
An expression the database computes rather than a value the caller writes. See computed fields. |
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. |
Options are checked against each other
Section titled “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:
lengthbelongs to a string column,precision,scaleandautoIncrementto a numeric one,dimensionsanddistanceto a vector.- An unstored
computedfield 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. Withstored: trueit is a real column, so all of those apply again except the ones that would write to it. onUpdateneeds an update to fire on, so it cannot joinupdatable: false; a primary key isNOT NULLin every engine, so it cannot benullable: true. Only a contradiction is rejected:nullable: falseon a key states what the key already is, and compiles.
Optional means nullable
Section titled “Optional means nullable”A property’s ? is what makes the column nullable, so declare the ones that cannot be null without it:
@Field({ type: String }) title!: string; // NOT NULL@Field({ type: Date }) publishedAt?: Date; // nullableIt shows up on every read: $select: { title: true } gives title: string, while a nullable column stays Date | undefined, because NULL is a value the database can return. Writes are unaffected either way, since insert and update payloads are partial in their own right.
Primary Key Options
Section titled “Primary Key Options”The @Id decorator also supports:
| Option | Type | Description |
|---|---|---|
autoIncrement |
boolean |
Explicitly enable/disable auto-increment. Defaults to true for numeric types, false for strings/UUIDs. |
Composite Primary Keys
Section titled “Composite Primary Keys”Declaring @Id more than once makes the primary key composite, in declaration order:
import { Entity, Id, Field } from 'uql-orm';
@Entity()export class Enrolment { @Id({ type: Number }) studentId?: number;
@Id({ type: String }) courseId?: string;
@Field({ type: String }) grade?: string;}The table gets one PRIMARY KEY ("studentId", "courseId"), and a row is addressed by an object carrying every key:
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
Section titled “Pointing at a composite key”A foreign key to a composite key is several columns, so declare the relation rather than a column:
import { Entity, Id, ManyToOne } from 'uql-orm';
@Entity()export class Note { @Id({ type: Number }) id?: number;
// one column per key: `enrolmentStudentId` and `enrolmentCourseId` @ManyToOne({ entity: () => Enrolment }) enrolment?: Enrolment;}@Field({ references: () => Enrolment }) is refused here: one column cannot reference two. The derived columns are <relation><Key>, under one FOREIGN KEY spanning them - 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
Section titled “What is not supported yet”Every key is taken by reads, inserts and cascading deletes alike. What is left needs a name for a row in a place that holds one value, and each refuses by name rather than guessing:
| Path | Why |
|---|---|
| The id an insert reports | The rows insert; the id comes back undefined, since an id is one column’s value. You wrote every column of the key, and idOf(getMeta(Enrolment), row) names the row. |
saveOne / saveMany |
Save reads an id as proof a row exists, which a composite carries on an insert too. Use insertMany, or upsertMany - it asks the database instead. |
| 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
Section titled “Choosing Your Primary Key Strategy”// Auto-increment integer (simple, database-managed)@Id({ type: Number })id?: number;
// UUID (portable, client-generated)@Id({ type: 'uuid', onInsert: uuidv7})id?: string;