Skip to main content

Defining Models

Overview

A model — SQB calls it an entity — is a plain TypeScript class whose properties are annotated with decorators (@Column, @PrimaryKey, @Link, ...) that describe how the class maps to a database table. No schema file, no separate mapping config: the class itself, plus its decorators, is the mapping. Once a class is registered as an entity, SqbClient/SqbConnection can hand you a Repository for it that knows how to build the SELECT/INSERT/UPDATE/DELETE statements for you.

Every property on an entity is one of three kinds, plus a handful of entity-level concerns layered on top of the class as a whole:

ConceptDecorator(s)Covers
Entities@EntityRegisters a class as an entity, sets its table name/schema/comment.
Data Columns@ColumnMaps a property to a plain column — data type, nullability, default value, insert/update participation.
Embedded Objects@EmbeddedGroups a set of columns on the same table under a nested object property, without a join.
Associations@LinkRelates an entity to rows in another table — to-one, to-many, or a multi-hop chain — resolved lazily, only when a query's projection asks for it.
Primary Keys@PrimaryKeyMarks one or more columns as the entity's primary index, used to resolve a row by key.
Indexes and Foreign Keys@Index, @ForeignKeyRecords index and foreign-key metadata — informational, and available to tooling built on top of entities.
Lifecycle Hooks@BeforeInsert, @AfterInsert, @BeforeUpdate, @AfterUpdate, @BeforeDestroy, @AfterDestroyMethods invoked around a Repository's create/update/delete operations.
Entity CompositionEntity.mixin, Entity.Pick, Entity.Omit, Entity.UnionBuilds new entity classes out of existing ones, carrying over both the class members and the entity metadata.

Decorators only ever describe metadata — applying @Entity/@Column/@Link to a class doesn't touch a database, open a connection, or run any SQL by itself. Metadata becomes useful once you ask SqbClient/SqbConnection for a Repository for the class: that's the object that actually reads the metadata to build queries, execute them, and map rows back onto instances of your entity.

Entities

import { BaseEntity, Column, Entity, PrimaryKey } from '@sqb/connect';

@Entity('countries')
export class Country extends BaseEntity {
@PrimaryKey()
@Column()
declare code: string;

@Column()
declare name: string;
}

@Entity is a class decorator with two call forms:

function Entity(options?: EntityOptions | string): ClassDecorator;
  • Entity('table_name') — shorthand that only sets the table name.
  • Entity({ tableName?, schema?, comment? }) — the full options object.

If you omit tableName entirely (@Entity() with no arguments, or without the tableName option), it defaults to the class name (target.name), used verbatim — no case conversion.

@Entity()
class Customer {} // tableName === 'Customer'

@Entity('customers')
class Customer {} // tableName === 'customers'

@Entity({ tableName: 'customers', schema: 'public', comment: 'Customer accounts' })
class Customer {}

See EntityOptions for the full field list (tableName, schema, comment).

note

Applying @Entity isn't strictly required to use @Column/@Embedded on a class — those decorators register entity metadata on first use too (this is how embedded object types work without their own @Entity). But without @Entity, the class has no tableName, so a Repository built from it will fail as soon as it tries to build SQL ("<Name> is not decorated with @Entity decorator").

Extending BaseEntity

BaseEntity is an optional base class that gives an entity instance a couple of convenience instance methods, backed by a hidden reference to the Repository that produced it:

export class BaseEntity {
constructor(partial?: any); // copies matching column fields from `partial`
destroy(): Promise<boolean>; // repo.delete(this)
exists(): Promise<boolean>; // repo.exists(this)
toJSON(): any; // overridden internally to serialize column fields
}
  • The constructor copies any own column-field values found on partial onto the new instance — handy for new Customer({ givenName: 'Jane' }).
  • destroy() and exists() only work on instances that were returned by a Repository (e.g. from findById/create/update), because that's what wires up the internal repository reference. A new Customer() you constructed yourself has no repository attached, so both methods resolve to false.

Extending BaseEntity is optional — a plain class with @Entity/@Column decorators works fine as a Repository<T> target too; you just lose destroy()/exists(). See the full API reference.

Extending another entity

Plain class inheritance also works between entities — class Customer extends BaseCustomer {} automatically inherits the base class's columns, embedded fields, associations, indexes, foreign keys, and event listeners, on top of whatever the subclass adds itself:

@Entity()
class BaseCustomer extends BaseEntity {
@PrimaryKey()
@Column()
declare id: string;

@Column()
declare name: string;

@Link()
declare country: Country;
}

@Entity()
class Customer extends BaseCustomer {
@Column()
declare code: string;
}

Entity.getFieldNames(Customer); // ['id', 'name', 'country', 'code']

This isn't special-cased for BaseEntity/@Entity classes specifically — the base class doesn't even need its own @Entity decorator, only field decorators (@Column, @PrimaryKey, ...), for its metadata to be inherited:

class Base {
@PrimaryKey()
@Column()
declare id: number;
}

@Entity()
class MyEntity extends Base {
@Column()
declare code: string;
}

Entity.getPrimaryIndex(MyEntity); // same columns as Entity.getPrimaryIndex(Base)

The mechanism is the same one behind Entity static helpers: @Entity/@Column/... all call EntityMetadata.define(), which — the first time it runs for a given class — looks up the constructor's prototype chain for an already-defined base entity and merges its fields/indexes/foreign keys/event listeners into the new metadata before the decorator applies its own options. This works transparently across any number of inheritance levels.

note

tableName is the one field that isn't inherited from the base once the subclass carries its own @Entity(...) decorator: @Entity always sets tableName to whatever you passed, or the subclass's own class name if you passed nothing — never the base's tableName. schema and comment, by contrast, are inherited when the subclass's own @Entity(...) call doesn't set them. If you want a subclass to keep mapping to the same table as its base (single-table inheritance), either don't re-apply @Entity on the subclass at all (add fields with bare @Column/@PrimaryKey instead), or pass the base's exact tableName explicitly.

See Entity Composition for combining metadata from classes that aren't related by inheritance.

Entity static helpers

The Entity decorator function also carries a namespace of static helpers for introspecting metadata at runtime — useful for building generic tooling on top of entities. These are not documented in the @sqb/connect README, but are part of the public API (exported from @sqb/connect):

HelperDescription
Entity.getMetadata(ctor)Returns the entity's EntityMetadata, or undefined if ctor isn't an entity.
Entity.getField(ctor, key)Returns the metadata for any field (column, embedded, or association) by property name.
Entity.getColumnField(ctor, key)Returns ColumnFieldMetadata for a column field; throws if the field exists but isn't a column.
Entity.getEmbeddedField(ctor, key)Returns embedded-field metadata; throws if the field isn't embedded.
Entity.getAssociationField(ctor, key)Returns association-field metadata; throws if the field isn't an association.
Entity.getColumnFieldByFieldName(ctor, fieldName)Looks a column up by its database field name instead of its property name.
Entity.getFieldNames(ctor, filter?)All field (property) names, optionally filtered.
Entity.getColumnFieldNames(ctor) / getEmbeddedFieldNames(ctor) / getAssociationFieldNames(ctor) / getNonAssociationFieldNames(ctor)Field names narrowed by kind.
Entity.getInsertColumnNames(ctor) / getUpdateColumnNames(ctor)Column names that participate in INSERT/UPDATE (i.e. not marked noInsert/noUpdate).
Entity.getPrimaryIndex(ctor)The entity's primary-key IndexMetadata, if any.
Entity.getPrimaryIndexColumns(ctor)The resolved ColumnFieldMetadata[] making up the primary key.
Entity.mixin, Entity.Pick, Entity.Omit, Entity.UnionClass-composition helpers — see Entity Composition.

Data Columns

@Column maps a class property to a database column. It can be used bare, with just a data type, or with a full options object:

function Column(type?: DataType): PropertyDecorator;
function Column(options?: ColumnFieldOptions): PropertyDecorator;
import { Column, DataType, Entity } from '@sqb/connect';

@Entity('customers')
class Customer {
@Column()
declare givenName: string;

@Column(DataType.CHAR)
declare gender: string;

@Column({
fieldName: 'birth_date',
dataType: DataType.DATE,
exclusive: true,
})
declare birthDate?: Date;
}

See ColumnFieldOptions for the full reference.

Type inference

You rarely need to set type/dataType explicitly — @Column infers them for you using TypeScript's design-time reflection metadata (Reflect.getMetadata('design:type', ...), which requires emitDecoratorMetadata):

  • If you don't pass type, it's read from the property's declared TS type.

    • If the declared type is Array, @Column sets type: String, isArray: true instead (i.e. it assumes an array of strings unless you say otherwise).
    • If the declared type is another @Entity-decorated class, @Column sets dataType: DataType.JSON automatically (a nested entity stored as a JSON column — different from @Embedded, which maps a nested object onto prefixed sibling columns of the same table).
  • If you don't pass dataType (and none was inferred from a nested-entity type above), it's derived from type:

    typeinferred dataType
    BooleanDataType.BOOL
    NumberDataType.NUMBER
    DateDataType.TIMESTAMP
    ArrayDataType.VARCHAR (and isArray: true)
    BufferDataType.BINARY
    anything else (default)DataType.VARCHAR
  • Conversely, if you pass dataType but not type, the reverse mapping fills in type (BOOLBoolean, VARCHAR/CHAR/TEXTString, NUMBER/DOUBLE/FLOAT/INTEGER/ SMALLINTNumber, TIMESTAMP/TIMESTAMPTZDate, BINARYBuffer, default→String).

@Column itself sets no other implicit defaultsnotNull, length, precision, etc. are left undefined unless you specify them.

ColumnFieldOptions reference

OptionTypeDescription
fieldNamestringDatabase column name. Defaults to the property name if omitted.
typeFunctionJS constructor for the value (String, Number, Boolean, Date, Buffer, or an @Entity class). Inferred from the TS type when omitted.
dataTypeDataTypeSQL data type — the DataType enum exported by @sqb/builder (BOOL, CHAR, VARCHAR, SMALLINT, INTEGER, BIGINT, FLOAT, DOUBLE, NUMBER, DATE, TIMESTAMP, TIMESTAMPTZ, TIME, BINARY, TEXT, GUID, JSON). Inferred from type when omitted.
commentstringColumn comment.
defaultFieldValue | DefaultValueGetterDefault value used on create()/createOnly() when the field is null/undefined. Can be a literal or a (obj) => value function that receives the full input object.
isArraybooleanMarks the column as an array column.
enumstring[] | number[] | objectRestricts accepted values; an array or an enum object (its Object.values() are used). Enforced on create/createOnly/update/updateOnly/updateMany — throws if the value isn't a member.
lengthnumberCharacter/byte length.
precisionnumberPrecision for a decimal field.
scalenumberScale for a decimal field.
collationstringColumn collation.
autoGeneratedColumnAutoGenerationStrategy'increment' | 'uuid' | 'rowid' | 'timestamp' | 'custom'. See below.
notNullbooleanRejects null values on create/update with an Error, unless the column is also autoGenerated.
noUpdatebooleanExcludes the column from UPDATE statements (update/updateOnly/updateMany).
noInsertbooleanExcludes the column from INSERT statements (create/createOnly).
parseColumnTransformFunction(value, name) => value — transforms a raw database value into the property value when reading rows.
serializeColumnTransformFunction(value, name) => value — transforms a property value into the value sent to the database when writing rows.
hiddenbooleanInherited from the shared field-metadata base; excludes the field from query results unconditionally, even when explicitly requested via projection.
exclusivebooleanInherited from the shared field-metadata base; excludes the field from results by default — it's only returned when explicitly named in projection (see Repositories → Projection).

autoGenerated doesn't make @sqb/connect generate the value itself — it only tells the create()/createOnly() code path "this column is allowed to be null/absent even though notNull is set, because the database (or a trigger) fills it in", and it's used by Repository.create()/createOnly() to know which columns to read back via RETURNING after an insert (the primary-key columns). The four strategy values themselves are just labels; nothing in @sqb/connect inspects 'increment' vs 'uuid' vs 'timestamp' vs 'custom' differently.

noInsert / noUpdate example

A common pattern for auto-managed timestamp columns:

@Column({
fieldName: 'created_at',
dataType: DataType.TIMESTAMP,
autoGenerated: 'timestamp',
noUpdate: true, // set once on insert, never touched again
})
declare createdAt?: Date;

@Column({
fieldName: 'updated_at',
dataType: DataType.TIMESTAMP,
autoGenerated: 'timestamp',
noInsert: true, // has no value on insert, only set by later updates
})
declare updatedAt?: Date;

parse / serialize

parse and serialize let you transform a value between its database representation and its JS representation. They can be set either as ColumnFieldOptions properties or via the dedicated @Parse / @Serialize decorators — both end up setting the same metadata field:

const GenderMap = { M: 'Male', F: 'Female' };

@Column(DataType.CHAR)
@Parse(v => GenderMap[v] || 'Unknown')
@Serialize(v => ('' + v).charAt(0))
declare gender: string;

Here the database stores a single character ('M'/'F'), parse expands it to a readable string when reading rows, and serialize collapses it back to a single character when writing.

Enum columns

@Column({
enum: ['red', 'green', 'blue', 'yellow', 'brown', 'white'],
default: obj => (obj.tag === 'small' ? 'yellow' : 'red'),
})
declare color: string;

If a value outside the enum list is written, @sqb/connect throws `${entity}.${field} value must be one of (...)` before issuing SQL.

Embedded Objects

@Embedded maps a nested object property onto a group of columns on the same table — as opposed to @Link, which relates to rows in a different table. It's useful for grouping related columns (an address, a person's name) under a single nested object in your entity's shape, without introducing a join.

function Embedded(
type?: TypeThunk,
options?: EmbeddedFieldOptions,
): PropertyDecorator;

Basic usage

import { Column, Embedded, Entity } from '@sqb/connect';

class PersonName {
@Column({ fieldName: 'given_name' })
declare given?: string;

@Column({ fieldName: 'family_name' })
declare family?: string;
}

@Entity('customers')
class Customer {
@Embedded(PersonName)
declare name: PersonName;
}

Here customer.name.given and customer.name.family read/write the given_name and family_name columns of the customers table directly — there's no name column and no join.

Like @Link's target argument, type can be a thunk (() => Type | Promise<Type>) to avoid circular imports; if omitted, it's inferred from the property's declared TS type via reflection (and an error is thrown if that isn't a class).

note

The embedded type (PersonName above) does not need its own @Entity decorator — only @Column-decorated properties, which is enough for @sqb/connect to register the metadata it needs. Giving it @Entity anyway is harmless (and lets you reuse the class as a real, independently queryable entity too), but it isn't required just to embed it.

fieldNamePrefix / fieldNameSuffix

Because an embedded object's columns live on the same table as everything else, you'll usually need a prefix (or suffix) to avoid name collisions between multiple embedded objects, or between an embedded object's columns and the parent's own columns:

class Address {
@Column()
declare city: string;

@Column()
declare street: string;

@Column({ fieldName: 'zip_code' })
declare zipCode: string;
}

@Entity('customers')
class Customer {
@Embedded(Address, { fieldNamePrefix: 'address_' })
declare address: Address;
}

With the prefix above, customer.address.city maps to the address_city column, customer.address.zipCode maps to address_zip_code, and so on — the prefix is prepended to each embedded column's own fieldName (which itself defaults to the property name, same as any other @Column).

fieldNameSuffix works the same way, appended after the field name instead of prepended before it. Both can be combined.

EmbeddedFieldOptions reference

OptionTypeDescription
fieldNamePrefixstringPrepended to every embedded column's fieldName.
fieldNameSuffixstringAppended to every embedded column's fieldName.
hiddenbooleanNever returned, even if explicitly requested in projection.
exclusivebooleanOnly returned when explicitly requested in projection.

Requesting sub-fields via projection

An embedded object's fields support the same dotted projection syntax as everything else — see Repositories → Projection:

const rows = await repo.findMany({ projection: ['name.given'] });
// rows[0].name === { given: '...' } -- only the "given" sub-field is populated

Embedding an @Entity class as JSON instead

Nesting another @Entity-decorated class as the declared type of a plain @Column (not @Embedded) is a different feature: @Column detects that case and sets dataType: DataType.JSON automatically, storing/reading the whole nested object as a single JSON column rather than spreading it across prefixed sibling columns. See Data Columns → Type inference.

Associations

@Link declares a relationship from one entity to another — a to-one join, a to-many eager sub-query, or a multi-hop chain of either (including a many-to-many relation through a join entity). Unlike a plain SQL JOIN, a @Link field is lazy: it's only fetched — and only adds a join/sub-query to the generated SQL — when a query explicitly asks for it via projection. Declaring dozens of @Link fields on an entity never slows down a query that doesn't request them.

The simplest form infers everything from the property's TypeScript type via reflection — fieldName, target key, and whether it's a to-one or to-many relation all default from context:

import { Link } from '@sqb/connect';
import type { Country } from './country.entity.js';

class Customer {
@Link({ exclusive: true })
declare readonly country?: Country;
}

Because no explicit .toOne()/.toMany() call is present, @Link() reads the property's declared type via design:type reflection:

  • If the type is an array, it throws unless you called .toMany(...) explicitly — a bare @Link() can't tell what element type an array holds.
  • If the type is a class, @Link() requires it to already be an @Entity-registered class and calls .toOne(thatClass) for you.
  • If the property's declared type doesn't match what the association returns (e.g. the target .toOne(...) call declares a to-many relation but the property type isn't an array, or vice versa), @Link throws a TypeError at decoration time:
    • Link returns single instance however property type is an array
    • Link returns array of instances however property type is not an array

Fluent form: .toOne() / .toMany()

@Link(options) returns a function that also carries .toOne() and .toMany() methods, so you can call it directly on the target type and be explicit about the join keys:

type LinkArgs<T> = {
sourceKey?: string; // column on *this* entity
targetKey?: keyof T; // column on the target entity
where?: object | object[];
};

toOne<T>(type: TypeThunk<T>, args?: LinkArgs<T>): LinkPropertyDecorator;
toMany<T>(type: TypeThunk<T>, args?: LinkArgs<T>): LinkPropertyDecorator;

type can be the target class itself, or a thunk () => Type | Promise<Type> — useful to break circular import cycles between entity files (see the async () => (await import(...)).Foo pattern in the examples below).

@(Link({ exclusive: true }).toOne(CustomerVip, {
sourceKey: 'id',
targetKey: 'customerId',
}))
declare readonly vipDetails: CustomerVip;

Note the decorator call is wrapped in parentheses (@(Link(...).toOne(...))) — this is required TypeScript decorator syntax any time the decorator expression is more than a bare identifier or call.

Key resolution

If you omit sourceKey/targetKey, @sqb/connect tries, in order:

  1. An existing @ForeignKey declared from the source entity to the target entity (or from the target back to the source) — its key pair is reused.
  2. Otherwise, a naming convention based on the target's primary key:
    • to-one: targetKey defaults to the target's single-column primary key; sourceKey defaults to <targetEntityName-camelCased>_<targetKey> (snake_case first, falling back to camelCase if a column with the snake_case name doesn't exist on the source).
    • to-many: the same convention in reverse — sourceKey defaults to the source's primary key, targetKey defaults to <sourceEntityName-camelCased>_<sourceKey> on the target.

This convention-based resolution only works when the target (for to-one) or source (for to-many) has a single-column primary key; for anything else, pass sourceKey/targetKey explicitly.

where — filtering the joined/related rows

where adds extra conditions (plain object or @sqb/builder operators, same syntax as a Repository filter) restricting which related rows are considered part of the relation:

@(Link({ exclusive: true }).toOne(CustomerVip, {
sourceKey: 'id',
targetKey: 'customerId',
where: { 'rank>=': 5 },
}))
declare readonly vvipDetails: CustomerVip;

Multi-hop (chained) associations

Calling .toOne()/.toMany() again on the return value of a previous call extends the chain by one more hop, walking through an intermediate entity without exposing it as its own field:

// Customer -> Country -> Continent, in one property
@(Link({ exclusive: true }).toOne(Country).toOne(Continent))
declare readonly continent: Continent;

Here Customer.continent isn't backed by a column on customers at all — it resolves through Country (via Customer.countryCodeCountry.code, using the Customer → Country association's own default key resolution) and then from Country to Continent (via Country.continentCodeContinent.code). Only the final target type (Continent) needs to match the property's declared type.

Many-to-many via a join entity

There's no dedicated "many-to-many" decorator — you model the join table as its own entity and chain .toMany() into it, then .toOne() out to the far side:

// customer-tag.entity.ts — the join table
@Entity({ tableName: 'customer_tags' })
@PrimaryKey(['customerId', 'tagId'])
class CustomerTag {
@Column({ fieldName: 'customer_id', notNull: true })
@ForeignKey(async () => (await import('./customer.entity.js')).Customer)
declare customerId: number;

@Column({ fieldName: 'tag_id', notNull: true })
declare tagId: number;
}

// customer.entity.ts
@(Link({ exclusive: true }).toMany(CustomerTag).toOne(Tag))
declare readonly tags?: Tag[];

Customer.tags first hops to CustomerTag (a to-many eager sub-query keyed on Customer.idCustomerTag.customerId, resolved from the @ForeignKey above), then to Tag (a to-one join keyed on CustomerTag.tagIdTag.id). Because the chain ends in a .toOne() after a .toMany(), the overall relation still returns many — the property type is Tag[], not CustomerTag[].

AssociationFieldOptions

type AssociationFieldOptions = Partial<
Omit<AssociationFieldMetadata, 'entity' | 'name' | 'kind' | 'association'>
>;

In practice this is the same hidden/exclusive pair every field kind shares:

OptionTypeDescription
hiddenbooleanNever returned, even if explicitly requested in projection.
exclusivebooleanOnly returned when explicitly requested in projection — used on essentially every @Link field in practice, since eagerly resolving every relation by default would be expensive.

Filtering by an association path

Beyond eager-loading, association paths can also be used inside a filter, without adding the relation to the result shape at all — @sqb/connect turns the path into a correlated EXISTS sub-query:

const vipCountries = await repo.findMany({
filter: { 'country.hasMarket': true },
});

See Repositories → Filters for filter syntax in general.

Primary Keys

@PrimaryKey registers one or more columns as the entity's primary index. Repository methods that take a keyValue (findById, delete, update, exists, ...) resolve it against this index.

@PrimaryKey has two overloads, used as a property decorator or a class decorator:

function PrimaryKey(
options?: Omit<IndexMetadata, 'columns' | 'unique' | 'primary'>,
): PropertyDecorator;

function PrimaryKey(
fields: string | string[],
options?: Omit<IndexMetadata, 'columns' | 'unique' | 'primary'>,
): ClassDecorator;

Single-column primary key

As a property decorator, @PrimaryKey() is placed directly on the key property, alongside @Column:

import { Column, DataType, Entity, PrimaryKey } from '@sqb/connect';

@Entity('customers')
class Customer {
@PrimaryKey()
@Column({ dataType: DataType.BIGINT, autoGenerated: 'increment' })
declare id?: number;
}

Used this way, @PrimaryKey() implicitly runs Column({ notNull: true }) on the property before registering it as the primary index — so the column is marked notNull: true even if you never call @Column yourself, and if you do stack @Column below it (decorators apply bottom-up), your own column options are merged on top of that implicit notNull: true.

Composite (multi-column) primary key

As a class decorator, @PrimaryKey(fields) takes the property name(s) that make up the key. This is the only way to declare a composite primary key, and it does not imply notNull on the referenced columns — declare that yourself if needed:

import { Column, Entity, ForeignKey, PrimaryKey } from '@sqb/connect';

@Entity({ tableName: 'customer_tags' })
@PrimaryKey(['customerId', 'tagId'], { name: 'pk_customer_tags' })
class CustomerTag {
@Column({ fieldName: 'customer_id', notNull: true })
@ForeignKey(async () => (await import('./customer.entity.js')).Customer)
declare customerId: number;

@Column({ fieldName: 'tag_id', notNull: true })
declare tagId: number;
}

When a Repository method needs a keyValue for a composite key, you must pass an object with all key fields (or an entity instance carrying them) — a bare scalar is only accepted for single-column keys:

await repo.delete({ customerId: 1, tagId: 5 });

Options

Both forms accept an options object typed as Omit<IndexMetadata, 'columns' | 'unique' | 'primary'> — in practice, just name:

OptionTypeDescription
namestringOptional name for the underlying index/constraint.

Internally, @PrimaryKey always creates an index with unique: true and primary: true; only one index per entity can be primary — the last one registered wins (registering a second primary index clears the primary flag off any earlier ones registered directly on that entity).

Reading primary-key metadata

import { Entity } from '@sqb/connect';

const idx = Entity.getPrimaryIndex(Customer); // IndexMetadata | undefined
const cols = Entity.getPrimaryIndexColumns(Customer); // ColumnFieldMetadata[]

See Entity.getPrimaryIndex/getPrimaryIndexColumns and Repositories for how keyValue arguments are resolved against the primary index at call time (extractKeyValues).

Indexes and Foreign Keys

@Index

@Index records index metadata on an entity. Like @PrimaryKey, it works both as a property decorator (single column, inferred from the property it's placed on) and as a class decorator (explicit column list, for composite indexes):

function Index(
options?: Omit<IndexMetadata, 'columns'>,
): PropertyDecorator;

function Index(
fields: string | string[],
options?: Omit<IndexMetadata, 'columns'>,
): ClassDecorator;
import { Column, Entity, Index } from '@sqb/connect';

class Customer {
@Index({ unique: true })
@Column({ fieldName: 'email' })
declare email: string;
}

@Entity('customers')
@Index(['countryCode', 'city'], { name: 'idx_customer_location' })
class Customer {
// ...
}

IndexMetadata reference

FieldTypeDescription
columnsstring[]Property names making up the index. Supplied automatically by the property-decorator form; required as the first argument to the class-decorator form.
namestringOptional index name.
uniquebooleanMarks the index as unique.
primarybooleanMarks the index as the primary key — set internally by @PrimaryKey; you would not normally set this yourself via @Index.

@Index purely records metadata (accessible via entity.indexes) — it does not generate DDL or alter query planning. It's there for introspection and for tools built on top of the entity metadata (e.g. a migration generator).

@ForeignKey

@ForeignKey records a foreign-key relationship from a column to another entity. It's a property decorator:

function ForeignKey(type: TypeThunk, targetKey?: string): PropertyDecorator;
import { Column, Entity, ForeignKey, PrimaryKey } from '@sqb/connect';

@Entity({ tableName: 'customer_tags' })
@PrimaryKey(['customerId', 'tagId'])
class CustomerTag {
@Column({ fieldName: 'customer_id', notNull: true })
@ForeignKey(async () => (await import('./customer.entity.js')).Customer)
declare customerId: number;

@Column({ fieldName: 'tag_id', notNull: true })
declare tagId: number;
}
  • type — the referenced entity, or a thunk (() => Type | Promise<Type>) to avoid circular imports, same as @Link and @Embedded.
  • targetKey — the referenced column on the target entity. When omitted, it's resolved the same way an unqualified @Link resolves its target key (defaults to the target's single-column primary key).

Declared foreign keys are stored on entity.foreignKeys and are looked up automatically by @Link when a link declares no explicit sourceKey/ targetKey of its own — so declaring @ForeignKey first can save you from having to repeat the key pair on every @Link that relates the same two entities.

Like indexes, @ForeignKey only records metadata for @sqb/connect's own use (key resolution, introspection) — it does not emit a DDL constraint.

Lifecycle Hooks

@sqb/connect ships six lifecycle decorators, exported from orm/decorators/events.decorator.ts: @BeforeInsert, @AfterInsert, @BeforeUpdate, @AfterUpdate, @BeforeDestroy, and @AfterDestroy. Each is a property decorator applied to a method:

function BeforeInsert(): PropertyDecorator;
function AfterInsert(): PropertyDecorator;
function BeforeUpdate(): PropertyDecorator;
function AfterUpdate(): PropertyDecorator;
function BeforeDestroy(): PropertyDecorator;
function AfterDestroy(): PropertyDecorator;
import { AfterInsert, Column, Entity } from '@sqb/connect';

class Customer {
@Column()
declare givenName: string;

@AfterInsert()
logCreated(): void {
console.log(`Customer ${this.givenName} created`);
}
}

Each decorator does exactly one thing: it registers the decorated method under a named bucket — 'before-insert', 'after-insert', 'before-update', 'after-update', 'before-destroy', or 'after-destroy' — on EntityMetadata.eventListeners:

const meta = Entity.getMetadata(Customer);
meta.eventListeners['after-insert']; // [Customer.prototype.logCreated]

Registering the same method under a different property, or subclassing the entity, is not required for the listener to survive — Entity.mixin copies eventListeners from a base entity onto a derived one, so hooks defined on a base class you mix in are preserved.

Important: these hooks are not currently invoked automatically

As of this version of @sqb/connect, EntityMetadata.eventListeners is only ever written by the six decorators above — nothing in Repository, or in the create/update/delete command implementations, reads it back or calls the registered functions during create/createOnly/update/updateOnly/updateMany/delete/deleteMany. The decorators are real, exported, and fully functional as metadata annotations (this is also exactly what their own test suite verifies), but attaching @BeforeInsert() to a method does not, by itself, make that method run when you call repo.create(...).

If you need code to run around a Repository operation today, your options are:

  • Call it yourself around the Repository call site.
  • Wrap the operation — e.g. build a thin service/repository-wrapper class around Repository<T> that runs your logic before/after delegating to it.
  • Read Entity.getMetadata(YourEntity).eventListeners['before-insert'] (etc.) yourself and invoke the listed functions at the point in your own code where that makes sense.

Keep this in mind before relying on @BeforeInsert/@AfterInsert/@BeforeUpdate/ @AfterUpdate/@BeforeDestroy/@AfterDestroy to run validation, auditing, or derived-field logic automatically — double check against the installed version's source (orm/commands/*.command.ts) if you're not sure whether this has changed.

Reference

DecoratorRegisters underTypical use (once wired up in your own code)
@BeforeInsert()'before-insert'Validate/derive fields before an insert.
@AfterInsert()'after-insert'React to a row having been created.
@BeforeUpdate()'before-update'Validate/derive fields before an update.
@AfterUpdate()'after-update'React to a row having been updated.
@BeforeDestroy()'before-destroy'Run cleanup/validation before a delete.
@AfterDestroy()'after-destroy'React to a row having been deleted.

Each decorator throws Property must be a function if applied to a non-function property, and You can define a Column for only string properties if applied to a symbol-keyed property.

Entity Composition

@sqb/connect ships a small set of class-composition helpers on the Entity namespace — Entity.mixin, Entity.Pick, Entity.Omit, and Entity.Union — that build new entity classes out of existing ones, copying both the runtime prototype members and the entity metadata (columns, embedded fields, associations, indexes, foreign keys, event listeners). They're real, exported, fully working parts of the public API (@sqb/connect's index.ts re-exports everything from entity.decorator.ts), but they are not mentioned anywhere in the @sqb/connect README — this section is their only documentation.

All four helpers are built on the same primitive: applyMixins() (copies prototype methods) plus EntityMetadata.mixin() (copies fields/indexes/foreign keys/event listeners), applied to a freshly created class.

Entity.mixin

Merges one or more base entities' columns, embedded fields, associations, indexes, foreign keys, and event listeners onto an existing class — mutating that class's own entity metadata in place and returning it (typed as the intersection of all the classes involved):

function mixin<A, B>(derivedCtor: Type<A>, baseB: Type<B>): Type<A & B>;
// ...overloads up to 5 base classes
import { Column, Entity } from '@sqb/connect';

class Timestamps {
@Column({ fieldName: 'created_at', autoGenerated: 'timestamp', noUpdate: true })
declare createdAt?: Date;

@Column({ fieldName: 'updated_at', autoGenerated: 'timestamp', noInsert: true })
declare updatedAt?: Date;
}

@Entity('customers')
class Customer {
@Column()
declare givenName: string;
}

Entity.mixin(Customer, Timestamps);
// Customer now also has createdAt/updatedAt columns, both at runtime
// (Customer.prototype gains Timestamps.prototype's own members) and in
// its entity metadata (Customer's ColumnFieldMetadata now includes both).

If Customer doesn't yet have a tableName/schema/comment of its own, mixing in a base that does have them copies those over too. Existing indexes, foreign keys, and event listeners on the base are copied across as well (re-targeted to the derived entity), as long as every column they reference exists on the derived side.

Entity.Pick

Builds a new class containing only the named keys from an existing entity — its metadata and prototype members are filtered down to that subset:

function Pick<T, K extends keyof T>(
classRef: Type<T>,
keys: readonly K[],
): Type<Pick<T, K>>;
const CustomerSummary = Entity.Pick(Customer, ['id', 'givenName', 'familyName']);

The key comparison is case-insensitive (keys are lower-cased before matching against field names). CustomerSummary is a genuinely new, independent entity class — its constructor copies matching own-properties out of a new Customer(...args) built from whatever arguments you pass it, so new CustomerSummary(data) behaves like new Customer(data) with everything outside keys stripped away.

Entity.Omit

The inverse of Pick — a new class with everything except the named keys:

function Omit<T, K extends keyof T>(
classRef: Type<T>,
keys: readonly K[],
): Type<Omit<T, K>>;
const CustomerWithoutAudit = Entity.Omit(Customer, ['createdAt', 'updatedAt']);

Same case-insensitive key matching and same "new independent class" semantics as Pick.

Entity.Union

Combines two or more entities' fields and prototype members into a brand-new class — like mixin, but building a fresh class instead of mutating an existing one:

function Union<A, B>(baseA: Type<A>, baseB: Type<B>): Type<A & B>;
// ...overloads up to 6 base classes
const CustomerWithTimestamps = Entity.Union(Customer, Timestamps);

The generated constructor applies each base's own-properties in order (using a new base(...args) instance built from whatever arguments you pass), so later bases in the argument list can overwrite fields set by earlier ones if they overlap.

When to reach for these vs. plain inheritance

Plain class Customer extends BaseCustomer {}-style inheritance already works for entities (see Extending another entityEntityMetadata.define() automatically merges a base class's metadata into a derived one it detects via the prototype chain when you decorate the subclass). Reach for Entity.mixin/Union instead when you need to combine metadata from classes that aren't related by inheritance (e.g. injecting a shared Timestamps "trait" into several unrelated entities), and reach for Entity.Pick/Omit when you want a narrower projection type for a specific Repository<T> call site (e.g. a lightweight DTO class) without re-declaring columns by hand.

Where to go next

  • Repositories — reading and writing entity rows.
  • ORM Overview — how the ORM fits with the query builder and connection layer.