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:
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:
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, ornull.record.author.set(user)points the record at another record, andset(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)andcreateOrThrow(attrs)save a new record at once, and point at it. The record itself isn’t saved.
| Option | Does |
|---|---|
foreignKey | The attribute on this record that holds the link. Defaults to the association’s name + Id |
primaryKey | The attribute on the target it points at. Defaults to the target’s primary key |
scope | Narrows 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:
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 authorrecord.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)andcreateOrThrow(attrs)save a new record at once. The owner must already be saved.insert,updateAll, anddeleteAllwrite to the owner’s records. See Writing many rows.
| Option | Does |
|---|---|
foreignKey | The attribute on the target’s records that holds the link. Defaults to this Model’s class name + Id |
primaryKey | The attribute on this record the foreign key points at. Defaults to the primary key |
scope | Narrows or orders the records, (q) => q.order(…). See Scoping associations |
dependent | What 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:
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 onceawait record.profile()reads the associated record, ornull. 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)andcreateOrThrow(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:
belongsTouses the association’s name +Id.author = belongsTo(Author)uses the record’s ownauthorId.hasManyandhasOneuse the Model’s class name +Id, on the target.Author’s associations useauthorId,BlogPost’s useblogPostId, andAPIKey’s useapiKeyId.
If the name doesn’t follow the convention, pass foreignKey:
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:
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, andawait 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:
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.
Creating related records
Records built or created through a hasMany get the owner’s key:
const comment = await article.comments().create({ body: "Lovely." }); // articleId filled inAutosaving
A record built through an association is saved with its owner. Build a whole graph, then save the owner:
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 batchThe 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:
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:
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:
| Strategy | What 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:
await article.comments().deleteAll(); // one DELETE for the article's commentsdeleteAll() 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]), withcomments = hasMany(Comment, { as: "commentable" })on each. - Counter caches (#61):
post = belongsTo(Post, { counterCache: true }), keeping acommentsCounton each post, updated in the same atomic batch as the write. - Inverse associations (#62): after
await post.comments().toArray(), eachcomment.post()returns that same post, without a query.