Querying & writing

Relations and nested writes

Read related records with select and include, and create, link, update and unlink them in one call with nested directives.

Read as Markdown

A Prisma relation (User.posts, Post.author, Post.category) is a field of its Resource. You read it by naming it in select or include, and write it with directives such as $create and $connect. The examples use User (has many Post), Post (belongs to a User, optionally to a Category) and Category.

Reading relations

With neither select nor include, a call returns every readable scalar field and no relations.

include

include adds relations to the default scalars:

Code
await avClient.User.find.first({ where: { id: 3 }, include: ["posts"] });
// {
//   avatar: null, createdAt: 2026-10-06T22:58:25.885Z, email: 'alan@example.com', id: 3, name: null,
//   passwordHash: '', role: 'USER', settings: null,
//   posts: [ { authorId: 3, bigViews: 0n, body: null, categoryId: 1, id: 6, price: AvDecimal {},
//             publishedAt: null, title: 'Enigma', views: 100 } ]
// }

select

select names exactly what you want, scalars and relations mixed. A relation named as a string uses the target's default scalars; to customize it, use the object form:

Code
await avClient.Post.find.first({
  where: { id: 1 },
  select: ["id", "title", { category: { select: ["name"] } }],
});
// { id: 1, title: 'Hello', category: { name: 'News' } }

await avClient.User.find.many({
  select: [
    "id",
    "name",
    { posts: { select: ["id", "title"], where: { views: { gt: 5 } }, orderBy: { views: "desc" }, limit: 2 } },
  ],
});
// [ { id: 1, name: 'Ada Lovelace', posts: [ { id: 1, title: 'Hello' } ] },
//   { id: 2, name: 'Grace Hopper', posts: [ { id: 3, title: 'Compilers' }, { id: 4, title: 'Navy stories' } ] },
//   { id: 3, name: null, posts: [ { id: 6, title: 'Enigma' } ] } ]

A to-many relation accepts its own where, orderBy, limit, offset and cursor, applied per parent row. A to-one relation that is empty reads as null:

Code
await avClient.Post.find.unique({ where: { id: 4 }, select: ["title", { category: { select: ["name"] } }] });
// { title: 'Navy stories', category: null }

select and include cannot be used together. A customized relation must be an object: { posts: ["id"] } is refused with V1008 ("Customized relation projection must be an object.").

Going deeper

Relations nest, up to maxNestingDepth (12 by default):

Code
await avClient.Category.find.many({
  select: ["name", { posts: { select: ["title", { author: { select: ["email"] } }] } }],
});
// [ { name: 'News',      posts: [ { title: 'Hello', author: { email: 'ada@example.com' } },
//                                 { title: 'Enigma', author: { email: 'alan@example.com' } } ] },
//   { name: 'Tutorials', posts: [ { title: 'Notes on engines', author: { email: 'ada@example.com' } },
//                                 { title: 'Compilers', author: { email: 'grace@example.com' } } ] } ]

To count related rows instead of loading them, use $count (Counting). To filter or order parents by their relations, see Filtering, sorting and paging.

Exposure can hide a relation, forbid including it, or narrow its pagination: Exposing and hiding fields.

Writing relations

Inside data of a create or update, a relation field takes directives. You may also set the foreign key directly (authorId: 1), which is simpler when you have it. Two limits apply:

  • Not together with the same relation: setting authorId and author: { $connect: … } in one record names the reference twice and is refused before the database is asked: 422 A2004 with a V1012 issue at the relation, Set "authorId" or "author", not both. It holds for required and optional relations, for create, update and upsert, for nested writes at any depth and for each step of a transaction. Either one alone is fine.
  • Not beside another relation's write (Prisma): a record that writes one relation through its field and another relation directly, such as authorId beside category: { $connect: … }, is refused with V1012: Use relation fields only, or reference fields only, in one record: "authorId" is a reference field and "category" is a relation. Write all the record's relations as relations, or all through their fields. A field beside a relation that holds no reference on that side (mentorId beside posts: { $create: … } on an author) is accepted.
  • Not back along the relation it is written through: in a Post created in User.posts, author and authorId are what the parent supplies. Writing either is refused before the database: 422 A2004, V1012 at that field (Relation "author" is the relation this Post is written through, from its other end ("User.posts"): the write it sits in supplies it.). The same holds in $connectOrCreate, $update and $upsert, at any depth, and it is a type error in typed calls. A self-relation's other edge stays writable.

Required relations and fields

A create must write every field the database requires and every required relation. The contract says which, per Resource, as createRequirements (Resources and operations), and the types follow it: in the generated client, framework.application, framework.client, the transaction builders and a stage's ctx, a create that leaves one out does not compile.

A required relation is written one way or the other: the relation (author: { $connect: … } or $create), or the field that holds its reference (authorId). Neither is a type error, and so is both:

Code
await avClient.Post.create.one({ data: { title: "Hi", authorId: 1 } });                     // ok
await avClient.Post.create.one({ data: { title: "Hi", author: { $connect: { id: 1 } } } }); // ok
// @ts-expect-error: write `author` or `authorId`
await avClient.Post.create.one({ data: { title: "Hi" } });
// @ts-expect-error: `author` and `authorId` both
await avClient.Post.create.one({ data: { title: "Hi", authorId: 1, author: { $connect: { id: 1 } } } });

When the reference has several fields (a composite key), the field way means all of them together. A create nested through a relation gets that relation from its parent, so a Post in User.posts: { $create: … } needs its title and nothing for its author. A $ref counts as a value. create.many and create.count records write no relation, so each one needs authorId (all the fields of a composite reference), and a record without it is refused with V1000 at data.<n>.authorId.

What a layer hides is not asked of its callers: if the client layer hides authorId, a client create needs author; if it hides author, it needs authorId. Hiding both (or exempting their create) while the layer can still create posts is refused at startup (COMPILER_HIDDEN_REQUIRED_FIELD, see Exposure). So is hiding authorId while the layer keeps create.many or create.count, even with author exposed: those records have no other way to name the author. Exempt the two variants in that layer, or keep authorId.

A field a compute.create stage fills is not required in the generated client, framework.application, framework.client and the transaction builders, whether the stage is written inline in createFramework, in a defineResource block, or in an overlay (where it counts for that layer only). A stage's ctx is the exception: its calls are typed from the adapter's model, with no layer or compute applied, so ctx.Post.create.one still asks for slug even where a compute.create fills it. Pass any value there (the compute overwrites it), or call through the façade. A request that gets past the types is refused the same way at runtime: A2004 with V1000 at the missing field (Relation "author" is required to create Post: write it, or "authorId".), or V1012 for both.

DirectiveMeaningOn createOn update
$createCreate related records and link them.yesyes
$connectLink existing records, by identifier.yesyes
$connectOrCreateLink if found, otherwise create.yesyes
$updateUpdate the related record(s); on a to-many, every record matching a where (required); its data takes what update.many's does: no relation writes on Prisma.yes
$upsertUpdate the match, otherwise create.yes
$deleteDelete the related record(s); on a to-many, those matching a where (required).optional to-one, to-many
$disconnectUnlink without deleting: true on an optional to-one, identifiers on a to-many.optional to-one, to-many
$setMake the relation exactly this set.to-many

Where an adapter's relation takes plain fields only in a nested write's where, as a to-many relation does with the Prisma 7 adapter, the where of $update and $delete is typed AvScalarWhereInput: each scalar field's filter and AND/OR/NOT, with no relation filter at any depth. A relation that advertises no directive for a write takes AvNoAdvertisedMember, which accepts {} and refuses every key. Both are exported from @aventara/core.

The directives a relation accepts are in the contract (capabilities.create / update), and exposure can narrow them. Directives are only valid in writes: in a where they answer V1006.

Code
// a Post linked to existing records
await avClient.Post.create.one({
  data: { title: "Connected", author: { $connect: { id: 1 } }, category: { $connect: { id: 2 } } },
  select: ["id", "title", "authorId", "categoryId"],
});
// { id: 7, title: 'Connected', authorId: 1, categoryId: 2 }

// a User and their Posts in one call
await avClient.User.create.one({
  data: { email: "linus@example.com", name: "Linus", posts: { $create: [{ title: "Kernel" }, { title: "Git" }] } },
  select: ["id", "email", { posts: { select: ["id", "title"], orderBy: { id: "asc" } } }],
});
// { id: 4, email: 'linus@example.com', posts: [ { id: 8, title: 'Kernel' }, { id: 9, title: 'Git' } ] }

// link a category, creating it when it does not exist yet
await avClient.Post.create.one({
  data: {
    title: "CoC",
    author: { $connect: { email: "ada@example.com" } },
    category: { $connectOrCreate: { where: { name: "Opinion" }, create: { name: "Opinion" } } },
  },
  select: ["id", "categoryId"],
});
// { id: 10, categoryId: 3 }

$connect takes an identifier: the primary key or any unique column ({ email: "..." }).

Code
// add a related record while updating the parent
await avClient.User.update.unique({
  where: { id: 1 },
  data: { posts: { $create: { title: "Nested" } } },
  select: ["id", { posts: { select: ["title"], orderBy: { id: "desc" }, limit: 1 } }],
});
// { id: 1, posts: [ { title: 'Nested' } ] }

// change related records matching a where
await avClient.User.update.unique({
  where: { id: 1 },
  data: { posts: { $update: { where: { title: "Nested" }, data: { views: { $increment: 5 } } } } },
  select: ["id", { posts: { select: ["title", "views"], where: { title: "Nested" } } }],
});
// { id: 1, posts: [ { title: 'Nested', views: 5 } ] }

// unlink an optional to-one
await avClient.Post.update.unique({ where: { id: 1 }, data: { category: { $disconnect: true } }, select: ["id", "categoryId"] });
// { id: 1, categoryId: null }

// replace a to-many with exactly this set; $disconnect unlinks given members only
await avClient.Category.update.unique({ where: { id: 1 }, data: { posts: { $set: [{ id: 1 }, { id: 3 }] } }, select: ["id", { posts: { select: ["id"], orderBy: { id: "asc" } } }] });
// { id: 1, posts: [ { id: 1 }, { id: 3 } ] }
await avClient.Category.update.unique({ where: { id: 1 }, data: { posts: { $disconnect: [{ id: 3 }] } }, select: ["id", { posts: { select: ["id"] } }] });
// { id: 1, posts: [ { id: 1 } ] }

// delete related records matching a where
await avClient.User.update.unique({ where: { id: 1 }, data: { posts: { $delete: { where: { title: "Nested" } } } }, select: ["id"] });

A to-many $update or $delete without a where is refused: V1012 ("To-many $update requires where.").

Upsert

upsert.unique creates the record when the identifier does not match, and updates it when it does:

Code
await avClient.User.upsert.unique({
  where: { email: "new@example.com" },
  create: { email: "new@example.com", name: "New" },
  update: { name: "Renamed" },
  select: ["id", "name"],
});
// first call:  { id: 4, name: 'New' }          (HTTP 200, A1008)
// second call: { id: 4, name: 'Renamed' }

What fails, and how

MistakeResult
$connect to a record that does not existAvNotFoundError, 404 A2003 ("The operation's arguments referenced a record that does not exist."), in a transaction step too
A nested write the target Resource's own guard refuses (a client $create of a Category when Category's create guard says no)AvAuthError, A4001 (or the code the guard threw)
A foreign key that does not exist (authorId: 999)AvConflictError, A2008
$update / $delete on a to-many without whereAvValidationError, A2004 / V1012
A reference field and its own relation in one record (authorId with author: { $connect: … })AvValidationError, A2004 / V1012: Set "authorId" or "author", not both.
One relation written through its reference field and another written directly, on Prisma (authorId with category: { $connect: … })AvValidationError, A2004 / V1012: Use relation fields only, or reference fields only, in one record: …
A directive the contract does not offer on that relationAvValidationError, A2004 / V1006
A relation write in the data of a to-many $updateAvValidationError, A2004 / V1006 (the data is a bulk write)
A relation filter in the where of a to-many nested $update or $delete (at any depth, under AND, OR or NOT too)AvValidationError, A2004 / V1006 (below)

On a required relation, a directive that would leave a child with no parent (for example $set that drops a Post from its User) is refused by the database and answers AvConflictError, 409 A2008 ("The operation conflicts with the current state of the resource."). Nothing is written. This applies to User.update.unique with posts: { $disconnect: [{ id: 1 }] } or posts: { $set: [] } where Post.authorId is required.

Nested writes run the target's guards

A nested write is checked by the Resource it reaches, before the single database call. Post.create.one({ data: { category: { $create: { name: "x" } } } }) runs Category's own Resource and create guards, and its field pipes, validators, write guards and compute, on the nested data. A $update or $upsert runs the target's update guards, a $delete its delete guards, and a $connect, $disconnect or $set the target's update guards when the target's row is what changes. A guard there sees ctx.$operation.nested === true. The whole table is in Pipelines.

The where of a nested $update or $delete

Through a to-many relation, a nested $update or $delete filters plain fields only on Prisma 7. A relation filter in its where is refused before the database is asked, with A2004 and V1006, and it is a type error in the typed calls:

Code
await avClient.User.update.unique({
  where: { id: 1 },
  data: { posts: { $update: { where: { category: { name: "News" } }, data: { views: 1 } } } },
});
// AvValidationError A2004 (422): { path: ["arguments","data","posts","$update","where","category"], code: "V1006",
//   message: 'Relation filter "category" is not available here: a nested $update or $delete filter on this relation takes scalar fields only.' }

The refusal covers any depth and the AND, OR and NOT wrappers: { AND: [{ NOT: { comments: { some: { text: "x" } } } }] } is refused at ["arguments","data","posts","$update","where","AND",0,"NOT","comments"]. When the directive holds an array, the path names the entry: the same filter in the second $update entry is refused at ["arguments","data","posts","$update",1,"where","category"]. Every to-many directive's array ($create, $connect, $connectOrCreate, $update, $delete, $upsert, $set, $disconnect) is addressed this way. Filter the nested rows by their own fields ({ title: "Nested" }, { id: 3 }), or find the ids first and name them. A to-one relation's nested $update keeps relation filters. The limit belongs to the adapter and is stated in the contract, so a client generated before it was declared must be regenerated.

Scalar directives

On a number field, an object with one $-key acts on the current value instead of assigning:

Code
await avClient.Post.update.unique({ where: { id: 1 }, data: { views: { $increment: 5 } }, select: ["id", "views"] });
// { id: 1, views: 15 }
await avClient.Post.update.many({ where: { views: { gt: 50 } }, data: { views: { $multiply: 2 } }, select: ["id", "views"] });
// [ { id: 6, views: 200 } ]

$increment, $decrement, $multiply and $divide apply to numeric fields (Int, BigInt, Float, Decimal). A required to-one relation such as Post.author has no $disconnect or $delete.

One object holds one directive: { $increment: 1, $decrement: 2 } is a type error and is refused with 422 A2004 / V1001 at the field.

A $divide by zero is the database's to answer. PostgreSQL refuses the division, and SQLite computes NULL, which a required column refuses. Either way the call answers 422 A2004 / V1004 at ["arguments"] and nothing is written. On SQLite a nullable column stores the NULL.

See also