MasterRecord

Loading Related Data

Eager, explicit and lazy loading — the three EF Core patterns, on every engine.

Once you have declared relationships, there are three ways to get related rows into memory, exactly as in EF Core: eager (include / thenInclude), explicit (entry().load()) and lazy (await post.author). All of them run parameterized queries and feed the same identity map.

models.js
// The models used on this page
class Category { id(db) { db.integer().primary().auto(); } name(db)  { db.string(); } tags(db)     { db.hasMany('Tag'); } }
class Tag      { id(db) { db.integer().primary().auto(); } label(db) { db.string(); } category(db) { db.belongsTo('Category').nullable(); } posts(db) { db.manyToMany('Post'); } }
class Author   { id(db) { db.integer().primary().auto(); } name(db)  { db.string(); } posts(db)    { db.hasMany('Post'); } }
class Post     { id(db) { db.integer().primary().auto(); } title(db) { db.string(); } author(db)   { db.belongsTo('Author').nullable(); } tags(db) { db.manyToMany('Tag'); } }

Eager loading: include()#

include() compiles a LEFT JOIN into the query — EF’s Include. Accept a lambda string ('p => p.author') or a bare navigation name.

include.js
// One joined statement (EF Include) — belongsTo / hasOne / hasMany
const posts = await db.Post
  .where((p) => p.title.like($$), 'Eternia%')
  .include('p => p.author')          // lambda string...
  .include('tags')                   // ...or bare navigation name
  .toList();

posts[0].author.name;                // loaded
posts[0].tags.map((t) => t.label);   // manyToMany navigations load through the split loader automatically

thenInclude() — nested levels#

thenInclude() loads the next navigation level of the preceding include() — EF’s ThenInclude. It is implemented as EF’s split query: after the main query, one batched query per level (IN on the parent keys, chunked by 500), for belongsTo, hasOne, hasMany, hasManyThrough and manyToMany at any depth. Levels already hydrated by the SQL include are reused.

then-include.js
// Nested levels (EF ThenInclude) — chain again for deeper levels
const posts = await db.Post
  .include('p => p.tags')
  .thenInclude('t => t.category')
  .orderBy('p => p.id')
  .toList();

posts[0].tags[0].category.name;

// single()/first() run the nested includes too
const post = await db.Post.include('tags').thenInclude('category').where((p) => p.id == $$, id).single();

asSplitQuery()#

split-query.js
// EF AsSplitQuery: every include() loads as a separate batched query — no cartesian explosion, no N+1
const authors = await db.Author
  .asSplitQuery()                    // call it BEFORE include()
  .include('a => a.posts')
  .thenInclude('p => p.tags')
  .thenInclude('t => t.category')
  .toList();
// → 1 query for authors + ONE batched query per level (IN on the parent keys, chunked by 500)
When to split
Including several collections in one joined statement multiplies rows (the cartesian explosion EF warns about). Use asSplitQuery() for collection-heavy graphs; keep the single join for one reference navigation.

Explicit loading#

Load a navigation on demand for an entity you already have — EF’s Entry(e).Reference(n).Load() / Collection(n).Load():

explicit.js
const post = await db.Post.find(id);

// EF Entry(e).Reference(n).Load() / Collection(n).Load()
await db.entry(post).load('author');
await db.entry(post).reference('author').load();
const tags = await db.entry(post).collection('tags').load();
await db.loadNavigation(post, 'tags');               // same thing, context-level

db.entry(post).isLoaded('author');                   // true
db.isNavigationLoaded(post, 'tags');                 // true

Lazy loading#

MasterRecord’s drivers are asynchronous, so lazy loading is adapted: reading an unloaded navigation returns a Promise that loads it once and caches it. After that (or after include() / explicit load) the read is synchronous. Errors are thrown, never returned as strings, and an un-awaited lazy read cannot crash the process.

lazy.js
// Lazy loading is ON by default. An unloaded navigation returns a Promise that
// loads it ONCE (parameterized, any engine) and caches it:
const post = await db.Post.find(id);
const author = await post.author;     // first read: one SELECT
post.author.name;                     // afterwards the read is synchronous

// null means "loaded, nothing there"; undefined means "not loaded yet"
const orphan = await db.Post.asSplitQuery().include('author').first();
orphan.author;                        // null — a missing parent is NOT lazy-loaded again

// Opt out per navigation: unloaded reads are null until loaded explicitly
class Lazy { id(db) { db.integer().primary().auto(); } author(db) { db.belongsTo('Author').lazyLoadingOff(); } }

Relationship fix-up#

Assigning a navigation keeps the foreign key in sync and vice-versa — EF’s relationship fix-up:

fix-up.js
const post = await db.Post.find(id);
const bob  = await db.Author.find(bobId);

post.author = bob;           // sets post.author_id, marks the entity dirty (EF fix-up)
await db.saveChanges();      // UPDATE Post SET author_id = ? — the engines persist the KEY, never the object

post.author_id = aliceId;    // changing the FK invalidates the loaded parent...
(await post.author).name;    // ...so the next read re-resolves instead of returning Bob

// legacy idiom still works and keeps the FK in sync
post.author = aliceId;

Global query filters inside include()#

As in EF Core, an included navigation honours the target entity’s global query filters, and ignoreQueryFilters() on the root query applies to the whole query, every level included:

filtered-include.js
// Included navigations honor the target entity's global query filters (soft delete, tenant…)
this.dbset(Tag).queryFilter('softDelete', 't => t.deletedAt == null');

const posts = await db.Post.include('tags').toList();            // deleted tags are NOT in post.tags
const all   = await db.Post.ignoreQueryFilters().include('tags').toList();   // propagates to every level
const some  = await db.Post.ignoreQueryFilters(['softDelete']).include('tags').thenInclude('category').toList();

// a filtered-out belongsTo parent reads null
Note
include()does not take filter parameters of its own (EF’s filtered include). Use a named query filter on the target entity, or filter the loaded collection in memory.

Many-to-many skip navigations#

db.manyToMany('Tag') synthesizes the join entity (EF Core 5+ skip navigation) — see Relationships. Loading and editing the collection works like any EF collection navigation:

many-to-many.js
// Insert: link persisted targets by key, insert new targets first (EF cascade insert)
const post = db.Post.new();
post.title = 'Grayskull';
post.tags = [existingTag, otherTagId, { label: 'new' }];
await db.saveChanges();                 // one PostTag join row per element

// Loaded collections have EF's add()/remove() — nothing hits the DB until saveChanges()
const tags = await post.tags;
tags.add(anotherTag);
tags.add({ label: 'brand-new' });       // inserted first, then linked
await tags.remove(existingTag);         // schedules the join row's DELETE
await db.saveChanges();

// the same through entry()
db.entry(post).collection('tags').add(tag);
await db.entry(tag).collection('posts').load();   // reverse side

Projections & read-only graphs#

When you only need a few columns, project instead of loading entities. Projections bypass the change tracker entirely; for read-only entity graphs combine asNoTracking() with include().

projection.js
// SELECT only what you need — projections never track
const skus   = await db.Product.select('p => p.sku').orderBy('p => p.id').toList();
const cats   = await db.Item.select('i => i.cat').distinct().toList();
const emails = await db.User.where((u) => u.active == $$, true).pluck('email');   // SELECT email …
const dtos   = await db.User.toObjectList({ includeRelationships: false });          // plain objects, not tracked

// Read-only entity graphs: asNoTracking() + include()
const feed = await db.Post.asNoTracking().include('author').take(50).toList();
No join() / leftJoin()
LINQ-style join() is deliberately unsupported — include() / thenInclude() cover relationship loading, and db.query(sql, params) is the escape hatch for a hand-written JOIN. Calling join() throws a clear error saying so.