Array field
There is no f.array(). It is a chain method on every field, and it swaps that field's column for one jsonb column holding a list, and its filters for the multi-value set.
| Aspect | Before .array() | After |
|---|---|---|
| Column | the inner field's column | jsonb, whatever the inner type |
| Row value | T | T[] |
| Derived schema | the inner schema | z.array(inner) |
| Operator set | the inner set | selectMultiOps, membership |
getType() | "text", "number", … | "array" |
| Admin control | the inner control | ArrayField, a repeatable list |
It takes no arguments and adds no state past the wrapping. .required() and
.minItems() land the same on either side of it, so
f.text().required().array() and f.text().array().required() build one field.
import { collection } from "#questpie/factories";
export const posts = collection("posts").fields(({ f }) => ({
tags: f.text(40).array().maxItems(10), // string[] | null
ratings: f.number().array().required(), // number[]
steps: f.object({ label: f.text() }).array(), // { label: string | null }[] | null
}));Run questpie generate then questpie push. Each of those is one jsonb
column. No junction table, no extra rows.
Methods
Two methods only mean something on an array. Both sit on the base field class,
so they are callable before .array() and on a scalar field, where nothing
reads them.
| Method | Effect |
|---|---|
.minItems(n) | z.array(…).min(n) on writes, and the floor the admin remove button stops at |
.maxItems(n) | z.array(…).max(n) on writes, and the cap the admin add button stops at |
Shared modifiers apply to the list as a whole. .required() makes the column
NOT NULL and the key required on insert.
.localized() moves the jsonb column into the <collection>_i18n table, so
each locale holds its own list. Per-element validation survives the wrapping,
and f.text(40).array() still rejects a 41-character element.
Put `.default()` after `.array()`
.default() keeps the value it was handed and writes it straight onto the
column. f.text().array().default([]) gives the column an empty list.
f.text().default("").array() gives it the string, because the default was
set while the field was still a scalar.
Item types
Wrapping a field that has a column always gives the same jsonb column and the
same z.array(inner). What differs is the control the admin picks for one item.
| Inner field | Item control |
|---|---|
f.text(), f.textarea(), f.email() | The matching input |
f.number() | A number input |
f.select(options) | One dropdown per item, carrying the options |
f.object(fields) | A repeatable nested form, one per item |
| anything else | A plain text input |
That allowlist is five names plus the object branch above it. A type outside it
never sets itemType, and ArrayField defaults that to "text". So
f.boolean().array() gets a text input, and the strings it produces are what
z.array(z.boolean()) rejects on save.
Not for relations or uploads
QUESTPIE registers a relation by reading getMetadata().type, and .array()
rewrites that to "array". A wrapped f.relation(to) or f.upload() loses
its foreign key, its hydration and its picker, and keeps a bare jsonb column.
Reach for f.relation(to).multiple() or f.upload({through}) instead.
Reads and writes
On read, each element is hydrated through the inner field, so
f.datetime().array() hands back Date objects rather than strings. A stored
value that is not a JSON array throws a TypeError, which means a hand-written
migration leaving a scalar in the column breaks every read of that row.
On write, the field's own Zod schema overlays the column-derived one. That is
what makes z.array(inner) and the length bounds enforced rather than
decorative. A value that arrives as a string but parses as a JSON array is
decoded before it reaches the driver, so a list that was already stringified
once is stored as an array instead of being encoded a second time.
Filtering
An array carries selectMultiOps. Element operands are typed off the inner
field, so f.number().array() filters on number and f.datetime().array() on
the datetime input type.
| Operator | Operand | Compiles to |
|---|---|---|
containsAll | Item[] | col @> '[…]'::jsonb, holds every listed value |
containsAny | Item[] | col ?| ARRAY[…]::text[], holds at least one |
eq | Item[] | col = '[…]'::jsonb, the whole list in order |
length | number | jsonb_array_length(col) = n |
isEmpty | boolean | col = '[]'::jsonb OR col IS NULL |
isNotEmpty | boolean | col != '[]'::jsonb AND col IS NOT NULL |
isNull / isNotNull | boolean | Pass false and each inverts |
const { docs } = await app.collections.posts.find({
where: { tags: { containsAll: ["ts", "cms"] } },
});containsAll and eq serialize the operand as JSON, so element types survive.
containsAny casts it to text[] for the ?| operator.
`contains` is typed but is not an array operator
The generated where type lists a single-element contains. selectMultiOps
has none, so the builder falls through to its generic contains and emits
ILIKE '%value%' against a jsonb column. Use containsAny: [value] to match
one element.
`isEmpty` and `isNotEmpty` ignore their operand
Both take the column and never read the value, so isEmpty: false compiles to
the same SQL as isEmpty: true. Pass isNotEmpty: true for the opposite.
isEmpty is also unparenthesized, and the builder brackets only the whole
AND group, so beside another filter its IS NULL arm escapes.
Two inner types lose the array filter type
select and json declare a whereInput on their state and .array()
carries it through. So f.select(…).array() types its where as one scalar,
eq / ne / in / notIn, while the runtime set is selectMultiOps. The
membership operators are unreachable through the typed clause.
In the admin
The form control is ArrayField, from @questpie/admin, a numbered list of the
item control with an add button under it. .minItems() and .maxItems() reach
it and gate the remove and add buttons. The list cell is ArrayCell, a count
badge beside the first item, with up to ten items in a hover tooltip.
Configure the rest with .admin(), the field extension that module registers.
| Key | Type | Default | Effect |
|---|---|---|---|
orderable | boolean | false | Adds the move up and move down buttons |
minItems | number | none | Same floor, set from the admin side |
maxItems | number | none | Same cap, set from the admin side |
mode | "inline" | "modal" | "drawer" | "inline" | Where an item is edited, object items only |
layout | "stack" | "inline" | "grid" | "stack" | How an item's fields sit, object items only |
columns | number | 2 | Grid width, object items only |
itemLabel | string | none | The item header, object items only |
steps: f.object({ label: f.text() }).array().admin({ orderable: true }),The last four are read only by the object-item component. A primitive array
takes orderable, minItems and maxItems and drops the rest.
Types
An array contributes Item[] to the row, insert and update shapes, and the
element type comes from the inner field.
type Post = typeof posts.$infer.select;
// ^? { tags: string[] | null; ratings: number[]; ... }.required() makes it non-null and required on insert, in either chain order.
.default() makes it optional on insert. .minItems() and .maxItems() are
runtime validation and have no type-level effect.
Related
Fields has the table of every type you can wrap.
Object is the inner field for a list of structured items, and the only one the admin gives a real nested form.
Relations is where a list of other rows belongs.
Collaborative documents covers
.crdt({ format: "set", conflict: "add-wins" }), which takes an array of
f.text({ mode: "text" }) that is required, defaults to [], and carries no
length bounds.
Validation covers the derived schema and .zod().