---
title: "belongs-to-many"
image: "https://adonia.pages.dev/og.png"
version: "next"
---

> Documentation Index
> Fetch the complete documentation index at: https://adonia.pages.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# belongs-to-many

<!-- Generated by `pnpm docs:fields` from packages/core/src/schema/fields/belongs_to_many.ts. Edit the TSDoc, not this file. -->

Multi-picker for a many-to-many relation.

| | |
| --- | --- |
| Builder | `F.belongsToMany(attribute: string): BelongsToMany` |
| Wire type key | `belongs-to-many` |
| Class chain | `BelongsToMany` → `RelationField` → `Field` |
| Detail projection | `relation-entry` |
| Column counterpart | — *(not tabulatable)* |

## Example

```ts
import { F } from '@adonia/core'

F.belongsToMany('tags')
  .resource('tags')
  .optionLabel('name')
  .searchable()
  .creatable()

// With pivot columns: state elements become `{ id, sort_order }`
F.belongsToMany('tags').pivotFields([F.number('sort_order').label('Order')])
```

## Props

Beyond the shared [field DSL](/reference/fields#the-shared-field-dsl):

| Prop | Declared on | Behaviour |
| --- | --- | --- |
| `pivotFields(schema: readonly SchemaComponent[]): this` | `BelongsToMany` | Declares a sub-form over the PIVOT columns (§7.3 `pivotFields(schema)`). |
| `resource(slug: string): this` | `RelationField` | Names the target resource explicitly (§7.3 `{ resource }`). Without it the target is inferred from the Lucid relation, which is right whenever exactly one resource manages the related model. |
| `relation(name: string): this` | `RelationField` | Lucid relation this field reads and writes. Defaults to the attribute with a `Id`/`_id` suffix stripped — `authorId` → `author` — which is the Lucid convention for a `belongsTo` foreign key. |
| `optionLabel(attribute: string): this` | `RelationField` | Target attribute rendered as the option label, overriding the target's `recordTitle` (§6.1). |
| `searchable(enabled = true): this` | `RelationField` | Renders the picker as an async combobox backed by the `urls.options` capability URL (protocol §4) instead of a static list. NO options ride the descriptor — that is the entire point: a resource with 200 000 rows must not serialize them into a form. |
| `preload(limit: number = RELATION_OPTIONS_PAGE_SIZE): this` | `RelationField` | Ships the first `limit` options inline as `props.options` while STILL emitting `urls.options` (protocol §4): the list renders without a round-trip, and typing past the preloaded page searches the rest. |
| `creatable(enabled = true): this` | `RelationField` | Lets the user create a target record from inside this form (§7.3), in a modal built from the TARGET resource's own descriptor. |
| `keyType(type: RelationKeyType): this` | `RelationField` | Primary-key shape of the target model, driving the §11.1 base (`vine.number()` vs `vine.string()`). Defaults to `'number'`: Lucid carries no SQL type metadata on `$columnsDefinitions`, so the shape cannot be introspected, and `increments('id')` is the overwhelming convention. UUID-keyed targets declare `.keyType('string')`. |

### Introspection

Read-only members this type adds — used by the compiler, the table derivation and
tests rather than by a schema author.

| Member | Declared on | Behaviour |
| --- | --- | --- |
| `detailProps(): JsonObject` | `BelongsToMany` | Chrome, the relation props, and `multiple: true` (protocol §8). |
| `nestedSchema(): readonly SchemaComponent[] \| undefined` | `BelongsToMany` | The pivot sub-form, emitted as the node's `children` (protocol §2). |
| `boundaryKeys(): readonly string[]` | `BelongsToMany` | `PIVOT_KEY` — the item's identity, and the one key of a pivot item that is NOT a pivot field (a pivot schema may not declare `id`). |
| `get hasPivotFields(): boolean` | `BelongsToMany` | Whether the state elements carry pivot attributes. |
| `persistRelation(record: object, value: unknown, _ctx: HttpContextLike): Promise<void>` | `BelongsToMany` | Reconciles the pivot table with the submitted selection, inside the write transaction (see the module TSDoc for why a rebuild is wrong). |
| `get targetSlug(): string \| undefined` | `RelationField` | The declared target slug, when `.resource()` was called. |
| `get relationName(): string` | `RelationField` | The Lucid relation name (declared, or inferred from the attribute). |
| `get optionLabelAttribute(): string \| undefined` | `RelationField` | The declared option-label attribute, when any. |
| `get keyShape(): RelationKeyType` | `RelationField` | Primary-key shape of the target (§11.1). |
| `get preloadLimit(): number \| undefined` | `RelationField` | Inline-options page size, or `undefined` when `preload()` was not called. |
| `get isSearchable(): boolean` | `RelationField` | Whether the field offers server-side search (§7.3 `searchable()`). |
| `get capabilities(): readonly string[]` | `RelationField` | Protocol §4: `options`, unconditionally. |
| `get columnType(): string \| undefined` | `RelationField` | Derives to a `relation` cell only when a label attribute is declared: a table column showing raw foreign keys is noise, and the dotted key `author.fullName` is also what registers the relation for preloading (§18). A per-instance decision, like a choice field's `badge`/`text` split. |
| `get columnKey(): string` | `RelationField` | `author.fullName` — the dotted path the derived `relation` cell reads (§7.5). |
| `contributeHints(hints: EagerHints): void` | `RelationField` | §18: register the relation so a detail/edit page hydrating through it reads materialized data. Without this a 40-field form with three relation fields costs three extra queries per render, and `belongs-to-many` hydration costs one per record. |
| `get optionsResolver(): OptionsResolver \| undefined` | `RelationField` | The built-in options resolver (protocol §4), unless the author declared their own through `dependentOptions()` — an explicit resolver is a deliberate override of the target's row set and must win. |
| `resolveTarget(ctx: HttpContextLike \| undefined): Promise<RelationTarget \| undefined>` | `RelationField` | Resolves the target once per field instance (a resource instance is per-request, so this is a per-request memo, not a global cache). |
| `preloadedOptions(ctx: HttpContextLike): Promise<FieldOption[]>` | `RelationField` | The first page of options, for `preload()` — the same resolution the endpoint uses, so inline and searched options are the same rows in the same order. |

## Validation

Vine base: `vine.string()`, `vine.number()`.

§11.1: `vine.array(id)` with a BATCHED `exists` — one query for the whole
array (the batched relation-check contract; a per-element rule is prohibited, it is N+1 by
construction). With pivot fields the member is the item object, built by
the same `buildFieldObjectSchema` the resource validator uses, so every
§11.2 rule applies inside a pivot row too.

Contributed by `BelongsToMany.vineSchema()` in `packages/core/src/schema/fields/belongs_to_many.ts`. The shared modifiers (`required`, `nullable`, `requiredWhen`, `unique`, optionality) are applied on top by `Field.buildValidation`, and a `rules()` override replaces the base entirely — the validator compiler never branches on the field class.

## Declared in

- `packages/core/src/schema/fields/belongs_to_many.ts` — `BelongsToMany`
- `packages/core/src/schema/fields/relation.ts` — `RelationField`

Source: https://adonia.pages.dev/reference/fields/belongs-to-many/index.mdx
