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.

src/models.ts
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.books and Book.author are the two sides of one relation: one author, many books. Read from Book, the same relation is many-to-one: many books, one author. Book stores the key.
  • Book.tags and Tag.books are both many: a many-to-many relation. A join table, created for you, holds the pairs.
  • onDelete says 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.

ShapeOne sideOther sideKey stored
One-to-many, or many-to-one from the other sidef.many(() => Book)f.one(() => Author)on the one side
Many-to-manyf.many(() => Tag)f.many(() => Book)in a join table
One-to-onef.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.

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.

src/queries.test.ts
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:

src/queries.test.ts
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 },
)

connect adds links and disconnect removes them, from either side of the relation:

src/queries.test.ts
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:

src/queries.test.ts
await Book.update(
	{
		where: { id: kindred.id },
		data: { author: { connect: { id: ursula.id } } },
	},
	{ as: master },
)

An optional one is emptied with disconnect: true:

src/shapes.test.ts
await Category.update(
	{ where: { id: fantasy.id }, data: { parent: { disconnect: true } } },
	{ as: master },
)

set replaces every link of a many with the rows it names:

src/queries.test.ts
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:

src/queries.test.ts
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 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.

src/queries.test.ts
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 create of a Book needs the right to create a book.
  • Linking or unlinking a row from the other side updates that row: connect under Tag.books runs the update rule of the Book it links.
  • Within one relation, the operations run in this order: delete, then create and connectOrCreate, then disconnect and connect, then update.
  • The relation back to the row being written is not written again: a book created under a publisher cannot also connect its 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:

src/queries.test.ts
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.

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.

src/queries.test.ts
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:

src/queries.test.ts
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.

src/queries.test.ts
// `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:

src/queries.test.ts
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:

src/queries.test.ts
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.

onDeleteDeleting the targetDefault for
restrictis refused while a row points to ita required one
cascadedeletes the rows that point to it, toonever
setNullempties their keyan optional one

Book.author cascades: deleting an author deletes her books, and their links to tags. The tags stay.

src/queries.test.ts
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.

src/queries.test.ts
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.

src/shapes.ts
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.

src/shapes.test.ts
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 relation may target its own model: a category's parent and children.

src/shapes.ts
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:

src/shapes.test.ts
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:

src/shapes.ts
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' }),
	}),
})
src/shapes.test.ts
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:

src/shapes.ts
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' }),
	}),
})
src/shapes.test.ts
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.

src/shapes.ts
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:

src/shapes.test.ts
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 inverse and no onDelete, and is absent from a create's or an update's data. Write its rows through the relation it reads.
  • The filter's function receives { caller, where } and returns where({ ... }) or a boolean. It can depend on the caller.
  • .with({ relations }) also filters a declared many itself. Its filter applies to every read, Master's included. Writes still reach the rows it hides, so connect and disconnect work and set is refused.

Relations in access rules and hooks

  • A rule or a hook sees a one as { id }, or null when it is empty. A many is 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, in f.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:

src/app.test.ts
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:

src/app.test.ts
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)

OptionTypeDefaultEffect
optionalbooleanfalseThe row may have no related row; the field reads null.
onDelete'cascade' | 'restrict' | 'setNull'restrict if required, setNull if optionalWhat deleting the target does. Only on the side that stores the key.
default{ id }, or a function returning onenoneThe row a create links when it writes none.
inversestringfound by targetThe other side's field, when two relations join the same models.
dbNamestringthe field's nameThe stored key's column.
descriptionstringnoneShown in GraphQL and REST schemas.

f.many(target, options)

OptionTypeDefaultEffect
inversestringfound by targetThe other side's field, when two relations join the same models.
fromstringnoneMakes the field a view of this other many, filtered in .with({ relations }).
descriptionstringnoneShown 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

OperationOn a oneOn a many
create{ ...data }[{ ...data }]
connect{ id }[{ id }]
connectOrCreate{ where, create }[{ where, create }]
disconnecttrue, optional only[{ id }]
setnone[{ id }], alone
update{ ...data }[{ where, data }]
deletetrue, 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

FilterOn a one or a manyMatches the rows
isonewhose related row matches, or is empty for null
isNotonewhose related row does not match
somemanywith at least one related row that matches
everymanywhose related rows all match, or with none
nonemanywith no related row that matches

Errors

CodeWhenExample message
RELATION_RESTRICTEDA delete removes a row that a restrict relation points to.Book.publisher references a Publisher the delete removes.
NOT_FOUNDA 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_QUERYA 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_FAILEDA 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_VIOLATIONTwo 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:

MessageFix
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 many can face only one one of its target, unless both name each other with inverse.
  • Sorting by _count or through a relation reads every row the where selects before it sorts and pages.
  • connect and connectOrCreate find a row by id; connectOrCreate also by a unique field.
  • Not built yet: a nested upsert, createMany, updateMany and deleteMany.
  • A client reading through a transport gets at most the transport's take limit on each list, nested lists included, and GraphQL refuses a query nested deeper than its maxDepth.

On this page