Relations
Link models with `one` and `many`, write related rows in one call, read and filter through them, and choose what deleting a row does.
A relation links the rows of two models: an author's books, a book's tags. It
is declared on both sides, f.one on the side that points to one row and
f.many on the side that holds a list. UltimaBase stores the keys, keeps them
valid, and lets every query write, read, filter and sort through them.
The models of this page: an author writes books, a publisher prints them, tags sort them.
import { graphql } from '@ultimabase/graphql'
import { allow, defineModel } from 'ultimabase'
export const Author = defineModel({
name: 'Author',
use: [graphql()],
fields: (f) => ({
name: f.string(),
// One author, many books: `Book.author` is the other side.
books: f.many(() => Book),
}),
// Open to every client, to keep the example on relations.
}).with({ access: allow.public })
export const Publisher = defineModel({
name: 'Publisher',
use: [graphql()],
fields: (f) => ({ name: f.string(), books: f.many(() => Book) }),
}).with({ access: allow.public })
export const Tag = defineModel({
name: 'Tag',
use: [graphql()],
fields: (f) => ({
label: f.string({ unique: true }),
// Many tags, many books: a join table holds the pairs.
books: f.many(() => Book),
}),
}).with({ access: allow.public })
export const Book = defineModel({
name: 'Book',
use: [graphql()],
fields: (f) => ({
title: f.string(),
// Deleting an author deletes their books.
author: f.one(() => Author, { onDelete: 'cascade' }),
// Required and without `onDelete`: a publisher with books stays.
publisher: f.one(() => Publisher),
tags: f.many(() => Tag),
}),
}).with({ access: allow.public })The full example, with its tests: examples/relations.
Author.booksandBook.authorare the two sides of one relation: one author, many books. Read fromBook, the same relation is many-to-one: many books, one author.Bookstores the key.Book.tagsandTag.booksare bothmany: a many-to-many relation. A join table, created for you, holds the pairs.onDeletesays what deleting an author does to her books. Without it, a required relation refuses the delete and an optional one empties its key.
The code below runs on the server, as Master. A client sends the same arguments over GraphQL or REST, as Over GraphQL shows.
Declaring a relation
Each relation has two fields, one on each model, that find each other by their targets. A field without its other side is refused when the application starts.
| Shape | One side | Other side | Key stored |
|---|---|---|---|
| One-to-many, or many-to-one from the other side | f.many(() => Book) | f.one(() => Author) | on the one side |
| Many-to-many | f.many(() => Tag) | f.many(() => Book) | in a join table |
| One-to-one | f.one(() => Card, { optional: true }) | f.one(() => Member) | on the required side |
The target is a function, () => Book, so two models can point to each other
in any order in the file.
A one is required unless it is optional: true: a book cannot be created
without its author. A many is a list, [] when empty, and never required.
Writing related rows
A create or an update writes related rows in the same call, under the relation's field. Everything in the call is one write: if any part is refused, nothing is stored.
Create rows with their relations
create makes the related rows; connect links rows that exist, by id.
const ace = await Publisher.create({ data: { name: 'Ace' } }, { as: master })
// The author, her books and their tags, in one write.
const ursula = await Author.create(
{
data: {
name: 'Ursula K. Le Guin',
books: {
create: [
{
title: 'The Dispossessed',
publisher: { connect: { id: ace.id } },
tags: { create: [{ label: 'utopia' }, { label: 'classic' }] },
},
{
title: 'The Lathe of Heaven',
publisher: { connect: { id: ace.id } },
},
],
},
},
},
{ as: master },
)A one takes a single object; a many takes a list. A required one must be
written when the row is created, by connect or create:
const octavia = await Author.create(
{ data: { name: 'Octavia E. Butler' } },
{ as: master },
)
const kindred = await Book.create(
{
data: {
title: 'Kindred',
author: { connect: { id: octavia.id } },
publisher: { connect: { id: ace.id } },
},
},
{ as: master },
)Link and unlink rows
connect adds links and disconnect removes them, from either side of the
relation:
await Book.update(
{
where: { id: lathe.id },
data: { tags: { connect: [{ id: classic.id }] } },
},
{ as: master },
)
// The same link, written from the other side:
await Tag.update(
{
where: { id: classic.id },
data: { books: { disconnect: [{ id: lathe.id }] } },
},
{ as: master },
)Connecting a one replaces its row. Here a book moves to another author:
await Book.update(
{
where: { id: kindred.id },
data: { author: { connect: { id: ursula.id } } },
},
{ as: master },
)An optional one is emptied with disconnect: true:
await Category.update(
{ where: { id: fantasy.id }, data: { parent: { disconnect: true } } },
{ as: master },
)Replace a list, or link a row and create it if missing
set replaces every link of a many with the rows it names:
await Book.update(
{
where: { id: dispossessed.id },
data: { tags: { set: [{ id: classic.id }] } },
},
{ as: master },
)connectOrCreate links the row its where finds, by id or a unique field,
and creates it from create when there is none:
await Book.update(
{
where: { id: dispossessed.id },
data: {
tags: {
connectOrCreate: [
{ where: { label: 'utopia' }, create: { label: 'utopia' } },
{ where: { label: 'anarchy' }, create: { label: 'anarchy' } },
],
},
},
},
{ as: master },
)Update and delete related rows
update changes linked rows and delete deletes them. On a many, each one
names its rows with a where; only rows linked to this row are reached.
await Author.update(
{
where: { id: ursula.id },
data: {
books: {
update: [
{
where: { id: dispossessed.id },
data: { title: 'The Dispossessed: An Ambiguous Utopia' },
},
],
delete: [{ id: lathe.id }],
},
},
},
{ as: master },
)On a one, update takes the data directly and delete: true deletes the
linked row, as the one-to-one example shows.
What runs, and in which order
- Each nested row is written as if written alone: the target model's access
rules, field rules and hooks run for it. A nested
createof aBookneeds the right to create a book. - Linking or unlinking a row from the other side updates that row:
connectunderTag.booksruns the update rule of theBookit links. - Within one relation, the operations run in this order:
delete, thencreateandconnectOrCreate, thendisconnectandconnect, thenupdate. - The relation back to the row being written is not written again: a book
created under a publisher cannot also
connectits publisher. The types refuse it, and so does the server, for clients.
Refused writes
A connect to a row that does not exist fails with NOT_FOUND. A write the
types refuse, such as linking a book back to the publisher being created,
throws an InvalidQuery (INVALID_QUERY) when the types are bypassed, and
answers BAD_USER_INPUT, a 400, when a client sends it:
await expect(
Book.create(
{
data: {
title: 'The Word for World Is Forest',
author: { connect: { id: ursula.id } },
publisher: {
connect: { id: '01890000-0000-7000-8000-000000000000' },
},
},
},
{ as: master },
),
).rejects.toMatchObject({
code: 'NOT_FOUND',
message: 'A connected Publisher does not exist.',
})
await expect(
Publisher.create(
{
data: {
name: 'Tor',
books: {
create: [
{
title: 'The Left Hand of Darkness',
author: { connect: { id: ursula.id } },
// @ts-expect-error -- the publisher is the one being created
publisher: { connect: { id: ace.id } },
},
],
},
},
},
{ as: master },
),
).rejects.toThrow(InvalidQuery)The errors list every refusal.
Reading related rows
Include and select
include adds related rows to the result. A many in an include takes the
arguments of a findMany: where, orderBy, take, skip, cursor, and a
nested include or select.
const author = await Author.findUniqueOrThrow(
{
where: { id: ursula.id },
include: {
books: {
orderBy: { title: 'asc' },
take: 10,
include: { publisher: true, tags: true },
},
},
},
{ as: master },
)
expect(author.books.map((book) => book.title)).toEqual([
'The Dispossessed',
'The Lathe of Heaven',
])
expect(author.books[0]?.publisher.name).toBe('Ace')select returns only the fields it names, relations included, at any depth.
The result's type follows it:
const books = await Book.findMany(
{
where: { title: 'Kindred' },
select: { title: true, author: { select: { name: true } } },
},
{ as: master },
)
expect(books).toEqual([
{ title: 'Kindred', author: { name: 'Octavia E. Butler' } },
])Filter through a relation
A where reaches the related rows: is and isNot on a one, some,
every and none on a many. They nest, so a filter can cross several
relations.
// `is` and `isNot` on a `one`
const hers = await Book.findMany(
{ where: { author: { is: { name: 'Octavia E. Butler' } } } },
{ as: master },
)
expect(hers.map((book) => book.title)).toEqual(['Kindred'])
// `some`, `every` and `none` on a `many`
const withClassic = await Author.findMany(
{ where: { books: { some: { tags: { some: { label: 'classic' } } } } } },
{ as: master },
)
expect(names(withClassic)).toEqual(['Ursula K. Le Guin'])
const untagged = await Author.findMany(
{ where: { books: { every: { tags: { none: {} } } } } },
{ as: master },
)
expect(names(untagged)).toEqual(['Octavia E. Butler'])is: null finds the rows whose optional one is empty, isNot: null those
where it is set. every is true for a row with no related rows.
Count and sort
_count counts the related rows of each many, with a where if needed:
_count: { select: { books: { where: { title: 'Kindred' } } } }. An orderBy
sorts by that count:
const authors = await Author.findMany(
{
orderBy: { books: { _count: 'desc' } },
include: { _count: { select: { books: true } } },
},
{ as: master },
)
expect(authors.map((author) => [author.name, author._count.books])).toEqual(
[
['Ursula K. Le Guin', 2],
['Octavia E. Butler', 1],
],
)An orderBy also sorts through a one, by a field of the related row:
const books = await Book.findMany(
{ orderBy: [{ author: { name: 'asc' } }, { title: 'asc' }] },
{ as: master },
)
expect(books.map((book) => book.title)).toEqual([
'Kindred',
'The Dispossessed',
'The Lathe of Heaven',
])What a caller cannot read
A row the caller's access rules hide is absent everywhere: from an include,
from a _count, and from the rows a filter matches. A required one whose row
is hidden reads as null.
Deleting a row that others point to
The onDelete of a one says what deleting its target does to the rows that
point to it.
onDelete | Deleting the target | Default for |
|---|---|---|
restrict | is refused while a row points to it | a required one |
cascade | deletes the rows that point to it, too | never |
setNull | empties their key | an optional one |
Book.author cascades: deleting an author deletes her books, and their links
to tags. The tags stay.
await Author.delete({ where: { id: ursula.id } }, { as: master })
expect(await Book.count({}, { as: master })).toBe(1)
expect(await Tag.count({}, { as: master })).toBe(2)Book.publisher restricts: a publisher with books cannot be deleted.
await expect(
Publisher.delete({ where: { id: ace.id } }, { as: master }),
).rejects.toMatchObject({
code: 'RELATION_RESTRICTED',
message: 'Book.publisher references a Publisher the delete removes.',
})An optional one is emptied: deleting a category leaves its children without
a parent, as A model related to itself shows.
setNull on a required relation is refused by the types and at startup.
A cascade runs the hooks of every row it deletes, but checks the access rules
only on the row the caller deletes. A many-to-many link is removed with either
of its rows, whatever onDelete says.
Other shapes
One-to-one
Both sides are one, and exactly one is optional. The required side stores
the key, and onDelete belongs to it.
export const Member = defineModel({
name: 'Member',
fields: (f) => ({
name: f.string(),
// Optional: a member may have no card yet.
card: f.one(() => Card, { optional: true }),
}),
})
export const Card = defineModel({
name: 'Card',
fields: (f) => ({
number: f.string({ unique: true }),
// Required: the side that stores the key. A card goes with its member.
member: f.one(() => Member, { onDelete: 'cascade' }),
}),
})A member has at most one card; a card always has its member.
A member that already has a card cannot connect or create another one:
the old card would be left without its member, and the write fails with
The Card it points to cannot be left without one. Update or delete the card
instead.
const ada = await Member.create(
{ data: { name: 'Ada', card: { create: { number: 'C-1' } } } },
{ as: master },
)
const card = await Card.findUniqueOrThrow(
{ where: { number: 'C-1' }, include: { member: true } },
{ as: master },
)
expect(card.member.name).toBe('Ada')
await Member.update(
{ where: { id: ada.id }, data: { card: { update: { number: 'C-2' } } } },
{ as: master },
)
// `delete: true` on the optional side deletes the linked card.
await Member.update(
{ where: { id: ada.id }, data: { card: { delete: true } } },
{ as: master },
)A model related to itself
A relation may target its own model: a category's parent and children.
export const Category = defineModel({
name: 'Category',
fields: (f) => ({
name: f.string(),
// Optional, so deleting a parent empties its children's `parent`.
parent: f.one(() => Category, { optional: true }),
children: f.many(() => Category),
}),
})parent is optional, so deleting a category empties its children's parent:
const fiction = await Category.create(
{
data: {
name: 'Fiction',
children: {
create: [{ name: 'Fantasy' }, { name: 'Science fiction' }],
},
},
},
{ as: master },
)
await Category.delete({ where: { id: fiction.id } }, { as: master })
const left = await Category.findMany(
{ orderBy: { name: 'asc' }, include: { parent: true } },
{ as: master },
)
expect(left.map(({ name, parent }) => [name, parent])).toEqual([
['Fantasy', null],
['Science fiction', null],
])Two relations between the same models
When two models are linked twice, each field names its other side with
inverse:
export const Branch = defineModel({
name: 'Branch',
fields: (f) => ({
city: f.string(),
outgoing: f.many(() => Transfer, { inverse: 'from' }),
incoming: f.many(() => Transfer, { inverse: 'to' }),
}),
})
// Two relations to Branch: each side names the other with `inverse`.
export const Transfer = defineModel({
name: 'Transfer',
fields: (f) => ({
copies: f.number({ integer: true }),
from: f.one(() => Branch, { inverse: 'outgoing' }),
to: f.one(() => Branch, { inverse: 'incoming' }),
}),
})const paris = await Branch.create({ data: { city: 'Paris' } }, { as: master })
await Branch.create(
{
data: {
city: 'Lyon',
incoming: {
create: [{ copies: 12, from: { connect: { id: paris.id } } }],
},
},
},
{ as: master },
)
const read = await Branch.findUniqueOrThrow(
{
where: { id: paris.id },
include: { outgoing: { include: { to: true } }, incoming: true },
},
{ as: master },
)
expect(read.outgoing.map((transfer) => transfer.to.city)).toEqual(['Lyon'])
expect(read.incoming).toEqual([])A many-to-many with fields of its own
The join table of a many-to-many holds only the two keys. To store more, a
role or a date, make the join a model with two one relations:
export const Reader = defineModel({
name: 'Reader',
fields: (f) => ({
name: f.string(),
memberships: f.many(() => Membership),
}),
})
export const Club = defineModel({
name: 'Club',
fields: (f) => ({
name: f.string(),
memberships: f.many(() => Membership),
}),
})
// A many-to-many with fields of its own: a model with two `one` relations.
export const Membership = defineModel({
name: 'Membership',
fields: (f) => ({
role: f.enumOf(['member', 'host'], { default: 'member' }),
reader: f.one(() => Reader, { onDelete: 'cascade' }),
club: f.one(() => Club, { onDelete: 'cascade' }),
}),
})const club = await Club.create(
{ data: { name: 'Thursday readers' } },
{ as: master },
)
await Reader.create(
{
data: {
name: 'Grace',
memberships: {
create: [{ role: 'host', club: { connect: { id: club.id } } }],
},
},
},
{ as: master },
)
const read = await Club.findUniqueOrThrow(
{
where: { id: club.id },
include: { memberships: { include: { reader: true } } },
},
{ as: master },
)
expect(
read.memberships.map(({ role, reader }) => [reader.name, role]),
).toEqual([['Grace', 'host']])Views and filtered relations
A view is a read-only many that shows some of the rows of another many of
the same model: from names that relation, and .with({ relations }) gives
the filter.
export const Series = defineModel({
name: 'Series',
fields: (f) => ({
title: f.string(),
volumes: f.many(() => Volume),
// A view: the volumes its filter keeps, read-only.
released: f.many(() => Volume, { from: 'volumes' }),
}),
}).with({
relations: {
released: { where: ({ where }) => where({ released: true }) },
},
})
export const Volume = defineModel({
name: 'Volume',
fields: (f) => ({
title: f.string(),
released: f.boolean({ default: false }),
series: f.one(() => Series, { onDelete: 'cascade' }),
}),
})It is read, filtered, counted and sorted like any many:
const series = await Series.create(
{
data: {
title: 'Earthsea',
volumes: {
create: [
{ title: 'A Wizard of Earthsea', released: true },
{ title: 'The Other Wind' },
],
},
},
},
{ as: master },
)
const read = await Series.findUniqueOrThrow(
{
where: { id: series.id },
include: {
released: true,
_count: { select: { volumes: true, released: true } },
},
},
{ as: master },
)
expect(read.released.map((volume) => volume.title)).toEqual([
'A Wizard of Earthsea',
])
expect(read._count).toEqual({ volumes: 2, released: 1 })- A view is not written: it takes no
inverseand noonDelete, and is absent from a create's or an update'sdata. Write its rows through the relation it reads. - The filter's function receives
{ caller, where }and returnswhere({ ... })or a boolean. It can depend on the caller. .with({ relations })also filters a declaredmanyitself. Its filter applies to every read, Master's included. Writes still reach the rows it hides, soconnectanddisconnectwork andsetis refused.
Relations in access rules and hooks
- A rule or a hook sees a
oneas{ id }, ornullwhen it is empty. Amanyis absent from the record. - The optional side of a one-to-one is absent from the record too: the key is stored on the other side.
- A field rule on a
many, inf.many(...).with(...), receives what the write does to it:{ connect, disconnect, create, delete }.
Over GraphQL
The graphql() plugin of each model serves the same writes and reads. A
client creates an author with her books in one mutation:
const body = await request(
`mutation ($publisher: ID!) {
createAuthor(data: {
name: "Ursula"
books: { create: [
{
title: "The Dispossessed"
publisher: { connect: { id: $publisher } }
tags: { create: [{ label: "utopia" }, { label: "classic" }] }
}
{ title: "The Lathe of Heaven", publisher: { connect: { id: $publisher } } }
] }
}) {
id
books(orderBy: [{ title: asc }]) { edges { node { id } } }
}
}`,
{ publisher: at(publisher, 'data', 'createPublisher', 'id') },
)A many is a connection, with edges { node } and totalCount, and takes
the same where, orderBy, take, skip and cursor:
const body = await request(
`query ($id: ID!) {
author(where: { id: $id }) {
books(orderBy: [{ title: asc }]) {
edges { node {
title
publisher { name }
tags(orderBy: [{ label: asc }]) { edges { node { label } } }
} }
}
}
}`,
{ id: author },
)
expect(at(body, 'data', 'author', 'books', 'edges')).toEqual([
{
node: {
title: 'The Dispossessed',
publisher: { name: 'Ace' },
tags: {
edges: [
{ node: { label: 'classic' } },
{ node: { label: 'utopia' } },
],
},
},
},
{
node: {
title: 'The Lathe of Heaven',
publisher: { name: 'Ace' },
tags: { edges: [] },
},
},
])The nested inputs are named after the relation, as in Prisma:
BookCreateWithoutAuthorInput, BookUpdateManyWithoutAuthorNestedInput. The
inputs of a one, such as { connect } or { create }, are @oneOf: they
take one operation. REST takes the same data, where, include and select
as JSON.
Reference
f.one(target, options)
| Option | Type | Default | Effect |
|---|---|---|---|
optional | boolean | false | The row may have no related row; the field reads null. |
onDelete | 'cascade' | 'restrict' | 'setNull' | restrict if required, setNull if optional | What deleting the target does. Only on the side that stores the key. |
default | { id }, or a function returning one | none | The row a create links when it writes none. |
inverse | string | found by target | The other side's field, when two relations join the same models. |
dbName | string | the field's name | The stored key's column. |
description | string | none | Shown in GraphQL and REST schemas. |
f.many(target, options)
| Option | Type | Default | Effect |
|---|---|---|---|
inverse | string | found by target | The other side's field, when two relations join the same models. |
from | string | none | Makes the field a view of this other many, filtered in .with({ relations }). |
description | string | none | Shown in GraphQL and REST schemas. |
.with({ relations: { field: { where } } }) on the model filters a many or
a view; where is ({ caller, where }) => where({ ... }) | boolean.
Nested writes
| Operation | On a one | On a many |
|---|---|---|
create | { ...data } | [{ ...data }] |
connect | { id } | [{ id }] |
connectOrCreate | { where, create } | [{ where, create }] |
disconnect | true, optional only | [{ id }] |
set | none | [{ id }], alone |
update | { ...data } | [{ where, data }] |
delete | true, optional only | [{ ...where }] |
set cannot be combined with connect or disconnect, and is refused when
the rows it would unlink cannot be left without this row (a required one on
the other side) and on a filtered relation.
Filters
| Filter | On a one or a many | Matches the rows |
|---|---|---|
is | one | whose related row matches, or is empty for null |
isNot | one | whose related row does not match |
some | many | with at least one related row that matches |
every | many | whose related rows all match, or with none |
none | many | with no related row that matches |
Errors
| Code | When | Example message |
|---|---|---|
RELATION_RESTRICTED | A delete removes a row that a restrict relation points to. | Book.publisher references a Publisher the delete removes. |
NOT_FOUND | A connect names a row that does not exist or that the caller cannot read; an update or delete names a row not linked to this one. | A connected Publisher does not exist., No Book matches data.books.update.0.where. |
INVALID_QUERY | A write names the same row twice, writes the relation back to its parent, or uses set where it is refused. | A Book cannot be left without its author., data.books.set cannot be used with connect or disconnect. |
VALIDATION_FAILED | A nested row's data is invalid, or a one-to-one would leave a row without its required side. The path names it. | path ['books', 'create', 0, 'title'] |
UNIQUE_VIOLATION | Two connectOrCreate create the same unique value at once. |
RELATION_RESTRICTED carries details: { model, field }, the relation that
refused. On the server, INVALID_QUERY is thrown as an InvalidQuery, with
its code and its path; a client receives it as BAD_USER_INPUT.
Startup errors
The application refuses to start, with one of these messages, when a relation is declared wrongly:
| Message | Fix |
|---|---|
Book.author: Author has no relation to Book; add its side. | Declare the other side. |
Book.author: Author has several relations to Book; name one with `inverse`. | Name the other side with inverse on both fields. |
Transfer.from: inverse 'outgoing' is not a relation of Branch to Transfer. | Name a field of the target that points back. |
Transfer.from and Branch.outgoing do not name each other. | Make the two inverse agree. |
Member.card and Card.member: make one side of a one-to-one relation optional. | Mark exactly one side optional: true. |
Member.card: onDelete belongs on Card.member, which stores the key. | Move onDelete to the required side. |
Book.author: setNull needs an optional relation. | Make the relation optional, or choose another onDelete. |
Book.author: its target is not in models. | Add the target to the application's models. |
Post.author: a plugin's model relates only to models of the same plugin; keep the id as a scalar. | Store the id in a string field. |
Series.released is a view and takes no inverse. | Remove inverse from the view. |
Series.released: from 'volumes' is not a many relation. | Point from at a many of the same model. |
Series.released: from 'volumes' relates to Volume, not Book. | Give the view the target of the relation it reads. |
Series.released: from 'latest' is a view; name its relation. | Point from at a declared relation, not another view. |
Series.released is a view; filter it in .with({ relations }). | Give the view its filter. |
Series.with.relations.title: title is not a many relation. | Filter only a many. |
Limits
- A
manycan face only oneoneof its target, unless both name each other withinverse. - Sorting by
_countor through a relation reads every row thewhereselects before it sorts and pages. connectandconnectOrCreatefind a row byid;connectOrCreatealso by a unique field.- Not built yet: a nested
upsert,createMany,updateManyanddeleteMany. - A client reading through a transport gets at most the transport's
takelimit on each list, nested lists included, and GraphQL refuses a query nested deeper than itsmaxDepth.