Skip to content

Associations ​

Associations connect Models: belongsTo, hasMany, and hasOne.

Defining associations ​

Declare each association as a field, passing the target Model’s class. The field’s name is the association’s name:

ts
import { belongsTo, hasMany, hasOne, Model } from "@forestfuture/d1-record";

export class Author extends Model({
  id: { type: "string", null: false, generateId: true },
  name: { type: "string", null: false },
}) {
  articles = hasMany(Article, { dependent: "destroy" }); // Article's authorId
  profile = hasOne(Profile, { dependent: "delete" }); // Profile's authorId
}

export class Article extends Model({
  id: { type: "string", null: false, generateId: true },
  authorId: { type: "string", null: false },
  title: { type: "string", null: false },
}) {
  author = belongsTo(Author); // its own authorId
  comments = hasMany(Comment, { dependent: "deleteAll" }); // Comment's articleId
}

export class Profile extends Model({
  id: { type: "integer", null: false },
  authorId: { type: "string", null: false },
  bio: "string",
}) {}

export class Comment extends Model({
  id: { type: "integer", null: false },
  articleId: { type: "string", null: false },
  body: { type: "string", null: false },
}) {}

Models can refer to each other in any order. A subclass inherits its parent’s associations, and can redeclare one to change it.

belongsTo ​

Use belongsTo on the Model whose table holds the foreign key. A post belongs to its author:

ts
const article = await Article.find(articleId);
const author = await article.author(); // an Author, or null

article.author.set(editor); // points it at another author: written when the article is saved
await article.save();
  • await record.author() reads the associated record, or null.
  • record.author.set(user) points the record at another record, and set(null) clears it. It’s written when the record is saved.
  • record.author.build(attrs) builds a record to point at. It’s validated with the record, and saved before it, so the foreign key can be filled in.
  • record.author.create(attrs) and createOrThrow(attrs) save a new record at once, and point at it. The record itself isn’t saved.
OptionDoes
foreignKeyThe attribute on this record that holds the link. Defaults to the association’s name + Id
primaryKeyThe attribute on the target it points at. Defaults to the target’s primary key
scopeNarrows the target, (q) => q.where(…). See Scoping associations

hasMany ​

Use hasMany on the Model the other table’s foreign key points at. A user has many posts:

ts
const author = await Author.find(authorId);
const articles = await author.articles().toArray(); // a Relation of the author's articles

author.articles().build({ title: "Untitled" }); // saved with the author, with its key
await author.save();

await author.articles().where({ title: "Untitled" }).deleteAll(); // bulk writes, scoped to the author
  • record.posts() is a Relation of the associated records, with every Relation method and the target’s scopes.
  • record.posts().build(attrs) builds a record with the owner’s key, saved with the owner. See Autosaving.
  • record.posts().create(attrs) and createOrThrow(attrs) save a new record at once. The owner must already be saved.
  • insert, updateAll, and deleteAll write to the owner’s records. See Writing many rows.
OptionDoes
foreignKeyThe attribute on the target’s records that holds the link. Defaults to this Model’s class name + Id
primaryKeyThe attribute on this record the foreign key points at. Defaults to the primary key
scopeNarrows or orders the records, (q) => q.order(…). See Scoping associations
dependentWhat destroying the owner does to the records. See Destroying dependent records

hasOne ​

Use hasOne like hasMany, when there’s at most one record. A user has one profile:

ts
const author = await Author.find(authorId);
const profile = await author.profile(); // a Profile, or null

if (profile === null) await author.profile.create({ bio: "Writer" }); // saved at once
  • await record.profile() reads the associated record, or null. If several records point at the owner, it’s the first by the scope’s order, then the primary key.
  • record.profile.build(attrs) builds a record with the owner’s key, saved with the owner.
  • record.profile.create(attrs) and createOrThrow(attrs) save a new record at once. The owner must already be saved.

hasOne takes the same options as hasMany.

Naming foreign keys ​

A foreign key names an attribute, such as authorId, rather than a column. By default, it’s inferred:

  • belongsTo uses the association’s name + Id. author = belongsTo(Author) uses the record’s own authorId.
  • hasMany and hasOne use the Model’s class name + Id, on the target. Author’s associations use authorId, BlogPost’s use blogPostId, and APIKey’s use apiKeyId.

If the name doesn’t follow the convention, pass foreignKey:

ts
import { belongsTo } from "@forestfuture/d1-record";

export class Post extends ApplicationRecord("posts") {
  author = belongsTo(User, { foreignKey: "userId" });
}

Keys are checked the first time a Model is used, and a missing attribute throws InvalidModel.

Bundlers and class names

Wrangler keeps class names by default, even when minifying. If a bundler renames Author to e, the inferred eId doesn’t exist, and the first use of the Model throws InvalidModel, saying where the key came from. Pass foreignKey, or keep class names in your bundler’s settings.

Caching loaded records ​

A loaded association is cached on the record. Reading author() again doesn’t query, and neither does articles().toArray() once the collection is loaded:

ts
const article = await Article.find(articleId);
const author = await article.author(); // an Author, or null
const profile = await author?.profile(); // a Profile, or null
const latest = await author?.articles().first();
  • Changing a foreign key makes the next read query again.
  • reload() clears the cache, and await record.reloadAssociation("articles") reloads one association.

TIP

A belongsTo whose foreign key is null, or an association on an unsaved owner, reads without a query.

Preloading associations ​

Load an association for every record at once with includes, with one query per association however many records there are:

ts
const authors = await Author.includes("profile", { articles: "comments" }) // 4 queries, however many authors
  .toArray();

for (const each of authors) {
  const articles = await each.articles().toArray(); // no query: already loaded
  console.log(each.name, articles.length, each.loaded("profile")?.bio);
}

Nest with an object: includes({ articles: "comments" }) or includes({ articles: ["comments", "tags"] }).

record.loaded("profile") reads a preloaded association synchronously, and throws AssociationNotLoaded if it wasn’t loaded, so a template never sends a query. record.isLoaded("profile") checks first.

Records built or created through a hasMany get the owner’s key:

ts
const comment = await article.comments().create({ body: "Lovely." }); // articleId filled in

Autosaving ​

A record built through an association is saved with its owner. Build a whole graph, then save the owner:

ts
const ada = Author.build({ name: "Ada" });
ada.articles().build({ title: "Notes on the Analytical Engine" });
ada.articles().build({ title: "Sketch of the Engine" });
ada.profile.build({ bio: "Mathematician" });
await ada.saveOrThrow(); // the author, both articles and the profile, in one batch

The built records are validated with the owner. An invalid article makes the author invalid (“Articles is invalid”), with its own errors on the article.

When the owner’s key is generated in code, as here, everything is written in one atomic batch.

Keys D1 generates

When D1 generates the key, with an INTEGER PRIMARY KEY, the key doesn’t exist until the owner is written. So the owner is written first, and its records follow in the next batch, a level at a time. The batches aren’t atomic together: if a record fails at the second level, the save throws with the owner already saved. Generate keys in code wherever a graph must save all at once.

Scoping associations ​

Narrow or order an association’s records with scope:

ts
import { hasMany, Model } from "@forestfuture/d1-record";

export class Blog extends Model({ id: { type: "integer", null: false } }) {
  // A scope for the association, as Rails' `has_many :posts, -> { … }`.
  recentPosts = hasMany(BlogPost, {
    scope: (q) => q.order({ createdAt: "desc" }).where({ draft: false }),
  });
}

The scope applies to reads and preloads alike, and records created through the association take its equality values. q is the target’s Relation, with its attributes and scopes.

TIP

A scope that uses limit, offset, or distinct can’t be preloaded, since it would apply to every owner’s records together. includes throws IncompatibleRelation.

A Model’s association to itself, scoped with its own scopes

TypeScript can’t infer this one case: children = hasMany(Category, { scope: (q) => q.top() }) inside Category, where top is one of Category’s scopes. Write the field’s type out:

ts
import { belongsTo, hasMany, Model, type HasMany } from "@forestfuture/d1-record";

export class Category extends Model({
  id: { type: "integer", null: false },
  parentId: "integer",
}) {
  static top = this.scope((q) => q.where({ parentId: null }));

  // Written out: TypeScript can't infer a field whose scope uses its own Model's scopes.
  children: HasMany<typeof Category> = hasMany(Category, {
    foreignKey: "parentId",
    scope: (q) => q.top(),
  });
  parent = belongsTo(Category, { foreignKey: "parentId" });
}

Destroying dependent records ​

Set what destroying the owner does to its records with dependent:

StrategyWhat happens
"destroy"Each record is destroyed with its own callbacks and dependents
"deleteAll" / "delete"The records are deleted in one statement, without callbacks ("delete" for a hasOne)
"nullify"Their foreign keys are set to NULL
"restrictWithException"The destroy throws DeleteRestriction while there are records
"restrictWithError"The destroy returns false, with an error on the owner’s base

Everything a destroy does, the owner’s DELETE included, is written in one atomic batch. Strategies run after the owner’s beforeDestroy callbacks, so an abort() leaves the records alone.

TIP

Without a strategy, it’s up to the database. If the table declares the foreign key with REFERENCES, destroying an owner whose records still point at it throws InvalidForeignKey.

Writing many rows ​

insert, updateAll, and deleteAll work through a hasMany, scoped to the owner’s records, and so do their to… forms for a batch:

ts
await article.comments().deleteAll(); // one DELETE for the article's comments

deleteAll() on the association follows its dependent strategy: it deletes the records for "destroy" or "deleteAll", and otherwise sets their foreign keys to NULL. See Bulk writes and batches.

Not built yet ​

These features aren’t in d1-record yet. The code shows the shape they’re likely to take.

  • Associations through another (#59): projects = hasMany(Project, { through: "assignments" }), a user’s projects reached through their assignments.
  • Polymorphic associations (#60): commentable = belongsTo([Post, Photo]), with comments = hasMany(Comment, { as: "commentable" }) on each.
  • Counter caches (#61): post = belongsTo(Post, { counterCache: true }), keeping a commentsCount on each post, updated in the same atomic batch as the write.
  • Inverse associations (#62): after await post.comments().toArray(), each comment.post() returns that same post, without a query.