s-m-r-t
Open the s-m-r-t source on GitHub Switch to dark color scheme
← Reference

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.

n-plus-one.ts
typescript
// 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.
eager-load.ts
typescript
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.