Skip to content
NewComposite primary keys5 min read

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.

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

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.

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

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

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

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

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.

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.

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.

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.
// Auto-increment integer (simple, database-managed)
@Id({ type: Number })
id?: number;
// UUID (portable, client-generated)
@Id({
type: 'uuid',
onInsert: uuidv7
})
id?: string;