Reference
Loading related objects without N+1 queries
Declared relationships load lazily one object at a time, or in batches for a whole page. Both paths fill the same per-object cache.
Verified against s-m-r-t 0.42.4
Where the extra queries come from
Listing 100 orders and then resolving the customer for each order inside the loop is 101 queries: one for the page and one per row. The count scales with the page size, so a small fixture hides it.
// One query for the page.
const page = await orders.list({ where: { status: 'open' }, limit: 100 });
// One more for every row.
for (const order of page) {
const customer = await order.loadRelated('customerId');
}include batches the relationship rather than joining
list({ include }) hydrates the page first. Then, it queries each named relationship in bulk and puts the result in each object cache. One hundred orders and their customer need 2 queries instead of 101. There is no JOIN or single-statement fetch. Thus, N+1 becomes 1+K.
- A @foreignKey, @crossPackageRef, or @oneToMany relationship costs one query. A @manyToMany costs two: it scans the junction table, then hydrates the targets.
- Foreign-key values are de-duplicated before the batch query, so repeated parents cost one row, not one per child.
- IN lists are chunked at 900 values, so a very wide page issues a few queries per relationship instead of one oversized statement.
- include is available on list() and findAll(). get() has no include option — call a loader on the object it returns.
- include cannot be combined with select. A projection returns plain rows with nothing to attach a relationship to, and asking for both throws.
const page = await orders.list({
where: { status: 'open' },
include: ['customerId', 'lines'],
limit: 100
});
for (const order of page) {
// Served from the primed cache; no further queries.
const customer = await order.getRelated('customerId');
}Three loaders, one cache
The loaders live on the object, take a relationship field name, and store what they resolve on that instance. A second call for the same field returns the cached value without querying.
- loadRelated(fieldName) resolves one @foreignKey or @crossPackageRef and returns the object, or null when the key is empty.
- loadRelatedMany(fieldName) resolves a @oneToMany through the inverse foreign key on the target, or a @manyToMany through its junction table, and returns an array.
- getRelated(fieldName) reads the relationship metadata and dispatches to whichever of the two applies. Use it when the call site does not need to know which kind the field is.
- The cache is per instance. A freshly listed object starts empty unless include primed it, so re-listing does not carry loaded relationships forward.
Loading stops at the tenant boundary
When a tenant-scoped object resolves a relationship into a different, non-null tenant, the loaders throw rather than returning the row. The check is a no-op for global objects and same-tenant reads, so it only fires on a genuine crossing.
- Pass { allowCrossTenant: true } for deliberate cross-tenant work such as admin tooling or migrations.
- A later guarded call checks a cache that include initialized. Thus, eager loading cannot move a cross-tenant object past the guard.
Where relationship declarations go wrong
Most relationship-loading surprises come from the declaration rather than the call site.
- A @oneToMany needs an inverse @foreignKey on the target. When the target declares more than one, name it — @oneToMany(Target, { foreignKey: "ownerId" }) — and a stale name throws instead of quietly returning empty arrays.
- A @manyToMany needs its junction table named in through; a missing one throws.
- include takes relationship field names, not target class or table names.
- Loading inside a component that renders per row puts the loop back. Prime the relationship where the query is issued, then read it during render.