Skip to content

Relations & Includes ​

Declare one/many relations on a model, then eager-load them with include on any find/findOne call - one-to-one, one-to-many, and many-to-many. For CRUD basics, start with the Repositories overview.

In one example ​

typescript
// Fetch a user with their posts
const user = await userRepository.findOne({
  filter: {
    where: { id: '123' },
    include: [{ relation: 'posts' }],
  },
});

// Result:
// {
//   id: '123',
//   name: 'John',
//   posts: [
//     { id: 'p1', title: 'First Post', authorId: '123' },
//     { id: 'p2', title: 'Second Post', authorId: '123' }
//   ]
// }

NOTE

An include in the filter routes the query through Drizzle's Query API (connector.query) instead of the Core API. The canUseCoreAPI check in ReadableRelationalRepository makes that choice automatically. Core API is ~15-20% faster but skips relations and field selection. See Performance Optimization.

TInclusion options ​

Each element of the include array accepts:

OptionTypeDefaultMeaning
relationstringrequiredName of the relation to include, matching an entry in the model's relations array
scopeTFilternoneNested filter on the related rows - where, order, limit, fields, include
shouldSkipDefaultFilterbooleanfalseSkip the related model's default filter for this inclusion only

scope takes the same shape as a top-level filter - see the Filter System reference for every where operator.

Declaring relations on a model ​

A model built with ModelFactory.defineEntity declares its relations keyed by name, each pointing at a table:

typescript
// src/models/user.model.ts
import { model } from '@venizia/ignis';
import { generateIdColumnDefs, many, ModelFactory, one } from '@venizia/ignis/postgres';
import { pgTable, text } from 'drizzle-orm/pg-core';
import type { AnyPgColumn } from 'drizzle-orm/pg-core';

export const userTable = pgTable('User', {
  ...generateIdColumnDefs({ id: { dataType: 'string' } }),
  name: text('name').notNull(),
});

export const postTable = pgTable('Post', {
  ...generateIdColumnDefs({ id: { dataType: 'string' } }),
  authorId: text('author_id').notNull().references((): AnyPgColumn => userTable.id),
  title: text('title').notNull(),
});

@model({ type: 'entity' })
export class User extends ModelFactory.defineEntity({
  table: userTable,
  relations: () => ({ posts: many(postTable, { relationName: 'author' }) }),
}) {}

@model({ type: 'entity' })
export class Post extends ModelFactory.defineEntity({
  table: postTable,
  relations: () => ({ author: one(userTable) }),
}) {}
  • one(userTable) needs no fields. postTable has exactly one foreign key to userTable, so the columns are read off it. The full rules, including two keys to one table, are in How one finds its columns.
  • A many pairs with the one its relationName names. A one relation's key is also its drizzle relationName, so posts pairs with author.
  • An inverse one pairs with the one back. profile: one(profileTable) on User, where the key is on profileTable, needs Profile to declare user: one(userTable) - see Pairing the inverse side.
  • Every relation is paired once, when the datasource builds its schema. A relation that cannot pair fails there, naming the entity and the relation - not on the first query that includes it.
  • The thunk runs on first use, once. That is what lets the two models live in two files without importing each other.

The class form ​

A class-based model declares a static relations resolver instead, returning an array of TRelationConfig. MetadataRegistry resolves the array during schema discovery and passes it to createRelations. createRelations builds the actual Drizzle relations() definition - application code never calls it directly.

typescript
// src/models/user.model.ts
import { model, RelationTypes } from '@venizia/ignis';
import { BaseEntity, TRelationConfig } from '@venizia/ignis/postgres';
import { pgTable, text } from 'drizzle-orm/pg-core';
import { Post } from './post.model';

@model({ type: 'entity' })
export class User extends BaseEntity<typeof User.schema> {
  static override schema = pgTable('User', {
    id: text('id').primaryKey(),
    name: text('name').notNull(),
    email: text('email').notNull(),
  });

  static override relations = (): TRelationConfig[] => [
    {
      name: 'posts',
      type: RelationTypes.MANY,
      schema: Post.schema,
      metadata: { relationName: 'author' }, // pairs with the `author` one on Post
    },
  ];
}

Write the resolver as an arrow function (() => [...]), not a plain array. IGNIS defers evaluation until every @model class has registered, which avoids circular-import ordering issues between related models.

TRelationConfig fields ​

FieldTypeMeaning
namestringRelation name used in include
typeRelationTypes.ONE | RelationTypes.MANYWhich Drizzle relation helper to build
schemaTTableSchemaWithIdThe related model's Drizzle table schema
metadatainferred from Drizzle's one()/many() paramsOptional for ONE: { fields?, references?, relationName? } - fields and references are read off the foreign key when left out. { relationName? } for MANY

metadata's shape comes straight from Drizzle's own one()/many() parameter types, not a hand-duplicated one.

Relation types ​

TypeDrizzle functionDescriptionExample
RelationTypes.ONE ('one')one()One-to-one or many-to-onePost has one Author, User has one Profile
RelationTypes.MANY ('many')many()One-to-manyUser has many Posts

NOTE

LoopBack 4 names these hasMany/hasOne/belongsTo. IGNIS uses Drizzle ORM's relation model instead, which has only one and many. A "belongsTo" relationship is type: RelationTypes.ONE - its fields (the local foreign key) and references (the remote primary key) are read off the table, or written in metadata.

A model with both types ​

This mirrors examples/vert's SaleChannelProduct junction model: one ONE relation per foreign key, plus a MANY relation elsewhere.

typescript
@model({ type: 'entity' })
export class Post extends BaseEntity<typeof Post.schema> {
  static override schema = postTable;

  static override relations = (): TRelationConfig[] => [
    {
      name: 'author',
      type: RelationTypes.ONE,
      schema: User.schema,
      metadata: {
        fields: [Post.schema.authorId],
        references: [User.schema.id],
      },
    },
    {
      name: 'comments',
      type: RelationTypes.MANY,
      schema: Comment.schema,
      metadata: { relationName: 'post' }, // pairs with the `post` one on Comment
    },
  ];
}

Auto-resolution in the repository ​

The repository never receives relations through its constructor. MetadataRegistry resolves them from the entity's static relations property. FilterBuilder.resolveRelations() reads and caches the result in a WeakMap the first time an include query needs them.

typescript
@repository({ model: User, dataSource: PostgresDataSource })
export class UserRepository extends DefaultCRUDRepository<typeof User.schema> {
  // Relations auto-resolved from User.relations!
}

Recipes ​

Include multiple relations ​

typescript
const post = await postRepository.findOne({
  filter: {
    where: { id: 'p1' },
    include: [{ relation: 'author' }, { relation: 'comments' }],
  },
});

Filter, order, and limit included rows ​

Combine where, order, limit, and fields inside scope the same way you would on a top-level filter:

typescript
// User with their 5 most recent published posts, id and title only
const user = await userRepository.findOne({
  filter: {
    where: { id: '123' },
    include: [{
      relation: 'posts',
      scope: {
        where: { status: 'published' },
        order: ['createdAt DESC'],
        limit: 5,
        fields: ['id', 'title', 'createdAt'],
      },
    }],
  },
});

Skip the default filter on one inclusion ​

Each inclusion can independently bypass the related model's default filter:

typescript
// Include soft-deleted posts that would normally be filtered out
const user = await userRepository.findOne({
  filter: {
    where: { id: '123' },
    include: [{ relation: 'posts', shouldSkipDefaultFilter: true }],
  },
});

Nest includes two levels deep ​

Put an include inside a scope to load a relation of a relation:

typescript
// User -> Posts -> Comments
const user = await userRepository.findOne({
  filter: {
    where: { id: '123' },
    include: [{
      relation: 'posts',
      scope: { include: [{ relation: 'comments' }] },
    }],
  },
});

Performance

Each nested include adds SQL complexity. Maximum 2 levels recommended. For deeper relationships, run multiple queries instead - see Performance Tips.

Many-to-many through a junction table ​

This is the pattern examples/vert uses for Product <-> SaleChannel through the SaleChannelProduct junction table. Include the junction relation, then nest the far side inside its scope.

typescript
// Product -> SaleChannelProduct (junction) -> SaleChannel
const product = await productRepository.findOne({
  filter: {
    where: { id: 'prod1' },
    include: [{
      relation: 'saleChannelProducts',
      scope: { include: [{ relation: 'saleChannel' }] },
    }],
  },
});

// Result:
// {
//   id: 'prod1',
//   name: 'Widget',
//   saleChannelProducts: [
//     { productId: 'prod1', saleChannelId: 'ch1', saleChannel: { id: 'ch1', name: 'Online Store' } },
//     { productId: 'prod1', saleChannelId: 'ch2', saleChannel: { id: 'ch2', name: 'Retail' } }
//   ]
// }

Count relations without fetching them fully ​

Fetch only id on the related rows to keep the payload small, then count the array client-side:

typescript
const users = await userRepository.find({
  filter: {
    include: [{ relation: 'posts', scope: { fields: ['id'] } }],
  },
});

const usersWithCounts = users.map(user => ({
  ...user,
  postCount: (user as any).posts?.length ?? 0,
}));

Include conditionally ​

typescript
async function getUser(id: string, includePosts: boolean) {
  const include = includePosts ? [{ relation: 'posts' }] : [];

  return userRepository.findOne({
    filter: { where: { id }, include },
  });
}

Type included results with a generic ​

For a model built with defineEntity, TEntityObject<typeof User> is the row plus every declared relation, each optional:

typescript
import type { TEntityObject } from '@venizia/ignis/postgres';

const user = await userRepository.findOne<TEntityObject<typeof User>>({
  filter: { where: { id: '123' }, include: [{ relation: 'posts' }] },
});

user?.posts?.[0].title; // string

For a class-based model, write the shape include produces as the type argument, so the result is fully typed instead of falling back to the base entity:

typescript
type UserWithPosts = User & {
  posts: Post[];
};

const user = await userRepository.findOne<UserWithPosts>({
  filter: {
    where: { id: '123' },
    include: [{ relation: 'posts' }],
  },
});

if (user) {
  console.log(user.posts[0].title); // Fully typed
}

The same pattern nests for a two-level include:

typescript
type ProductWithChannels = Product & {
  saleChannelProducts: (SaleChannelProduct & {
    saleChannel: SaleChannel;
  })[];
};

const product = await productRepository.findOne<ProductWithChannels>({
  filter: {
    where: { id: 'prod1' },
    include: [{
      relation: 'saleChannelProducts',
      scope: { include: [{ relation: 'saleChannel' }] },
    }],
  },
});

product?.saleChannelProducts[0].saleChannel.name; // Fully typed access

Hidden properties in relations ​

FilterBuilder.toInclude() excludes hidden columns from every included relation at the SQL level, the same way the top-level query does. For each inclusion it resolves the related model's hiddenProperties and default filter, and merges the default filter with your scope via mergeFilter(). It then drops hidden columns from the nested columns selection.

typescript
// User model has hiddenProperties: ['password']
const post = await postRepository.findOne({
  filter: { include: [{ relation: 'author' }] },
});

// post.author will NOT include password - excluded at SQL level

See Hidden Properties for the top-level equivalent.

Errors ​

ErrorCauseFix
[FilterBuilder][toInclude] Relation NOT FOUND | relation: 'x'include names a relation absent from the model's relations arrayCheck the model's relations definition and match the name exactly
[FilterBuilder][toInclude] Invalid include format | include: ...An include element has no relation stringGive every include element a relation field
[<Repository>] Schema key mismatch | Entity name 'X' not found in connector.queryThe model's TABLE_NAME doesn't match its schema registrationSee Query Interface Validation

Performance tips ​

  1. Limit nesting depth - max 2 levels recommended.
  2. Use fields in scope - fetch only the columns you need.
  3. Use limit in scope - don't fetch unbounded related data.
  4. Consider separate queries - for complex data needs, several simple queries often outperform one deeply nested one.
  5. Use shouldSkipDefaultFilter sparingly - only when you explicitly need filtered-out records.
typescript
// Instead of deep nesting, use separate queries
const user = await userRepository.findById({ id: '123' });
const posts = await postRepository.find({
  filter: { where: { authorId: '123' }, limit: 10 },
});
const comments = await commentRepository.find({
  filter: { where: { postId: { inq: posts.map(p => p.id) } } },
});

Quick reference ​

Want to...Code
Include one relationinclude: [{ relation: 'posts' }]
Include multipleinclude: [{ relation: 'posts' }, { relation: 'profile' }]
Filter includedinclude: [{ relation: 'posts', scope: { where: { status: 'active' } } }]
Order includedinclude: [{ relation: 'posts', scope: { order: ['createdAt DESC'] } }]
Limit includedinclude: [{ relation: 'posts', scope: { limit: 5 } }]
Nested includeinclude: [{ relation: 'posts', scope: { include: [{ relation: 'comments' }] } }]
Select fieldsinclude: [{ relation: 'posts', scope: { fields: ['id', 'title'] } }]
Skip default filterinclude: [{ relation: 'posts', shouldSkipDefaultFilter: true }]

See also ​

Files: