---
url: https://docs.forestfuture.dev/d1-record/guide/associations.md
description: >-
  belongsTo, hasMany, and hasOne: inferred foreign keys, reading, preloading
  with includes, autosave, writing through associations, scopes, and dependent
  strategies.
---

# 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.

| 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](#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](#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](#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](#scoping-associations)            |
| `dependent`  | What destroying the owner does to the records. See [Destroying dependent records](#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`.

::: details 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.

::: warning 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.

## Creating related records

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.

::: warning 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.

::: warning 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`.
:::

::: details 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`:

| 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.

::: warning 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](./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](https://github.com/forestfuture/d1-record/issues/59)): `projects = hasMany(Project, { through: "assignments" })`, a user’s projects reached through their assignments.
* **Polymorphic associations** ([#60](https://github.com/forestfuture/d1-record/issues/60)): `commentable = belongsTo([Post, Photo])`, with `comments = hasMany(Comment, { as: "commentable" })` on each.
* **Counter caches** ([#61](https://github.com/forestfuture/d1-record/issues/61)): `post = belongsTo(Post, { counterCache: true })`, keeping a `commentsCount` on each post, updated in the same atomic batch as the write.
* **Inverse associations** ([#62](https://github.com/forestfuture/d1-record/issues/62)): after `await post.comments().toArray()`, each `comment.post()` returns that same post, without a query.
