Object field
A fixed set of nested fields in one jsonb column, where every key carries its own schema, label, locale and access rule.
| Surface | Detail |
|---|---|
| Signature | f.object(fields) |
| Argument | Record<string, Field>, one entry per key, required |
| Column | jsonb |
| Schema | z.object() composed from each nested field's own schema |
| Filter operators | the object set, eleven entries, below |
| Admin form | a collapsible panel holding the nested controls |
| Admin cell | the first two keys inline, up to twelve behind a tooltip |
| Type-specific methods | none, the shared chain is the whole surface |
import { collection } from "#questpie/factories";
export const users = collection("users").fields(({ f }) => ({
email: f.email().required(),
address: f.object({
street: f.text().required(),
city: f.text().required(),
zip: f.text(10),
}),
}));address reads back as { street: string; city: string; zip: string | null } | null,
one jsonb value, no second table and no join.
Nested keys
Each value in the record is a field built from the same f, so it takes the
same chain. Another f.object() nests, to any depth. What a modifier on a
nested key actually changes:
| On a nested key | What it does |
|---|---|
.required() | Required in the composed schema, non-null in the read type |
.default(v) | Marks the key optional on input. No column exists to hold v |
.label(), .description() | Labels that key's control inside the panel |
.localized() | Moves that one key to the i18n table, below |
.access(rules) | Evaluated at the dot path, address.zip |
.outputFalse() | Key leaves the read type and is stripped from reads |
.inputFalse() | Writing the key is rejected with a 403 |
.array() | That key holds a list, composed as z.array() |
| Type refinements | Fold into the composed schema, f.text(10) caps that key at ten |
Undeclared keys are dropped
The composed z.object() strips keys you did not declare. A client that posts
an extra key gets it removed before the write, with no error. Declare every
key you intend to store.
Filtering
The typed where clause carries the object operator set. Every entry is
structural, and all of them run against the whole jsonb value.
| Operator | Operand | SQL |
|---|---|---|
contains | a partial object | col @> $1::jsonb |
containedBy | an object | col <@ $1::jsonb |
hasKey | string | col ? $1 |
hasKeys | string[] | col ?& ARRAY[keys]::text[] |
hasAnyKeys | string[] | col ?| ARRAY[keys]::text[] |
pathEquals | { path: string[]; val } | col #>> ARRAY[path]::text[] = $val |
jsonPath | string | col @@ $1::jsonpath |
isEmpty | boolean | col = '{}'::jsonb OR col IS NULL |
isNotEmpty | boolean | col != '{}'::jsonb AND col IS NOT NULL |
isNull | boolean | col IS NULL, inverted on false |
isNotNull | boolean | col IS NOT NULL, inverted on false |
const { docs } = await app.collections.users.find({
where: { address: { pathEquals: { path: ["city"], val: "Berlin" } } },
});
await app.collections.users.find({
where: { address: { contains: { city: "Berlin", zip: "10115" } } },
});Three sharp edges in that table. pathEquals extracts with #>>, so val is
compared as text and a number has to be written the way Postgres prints it.
hasKey and its two siblings see top-level keys only, never a key one level
down. isEmpty and isNotEmpty ignore their operand, so passing false runs
the same SQL as passing true.
There is no per-key operator
where: { address: { city: { eq: "Berlin" } } } compiles to nothing. The
condition is dropped and the query returns rows as though you had not
filtered. Use pathEquals or contains.
After .array()
.array() keeps the single jsonb column and stores a list of the object.
.minItems(n) and .maxItems(n) become real here, as z.array().min() and
.max(), and are inert on a bare object. The admin swaps the panel for a
repeatable item list.
links: f
.object({ label: f.text().required(), url: f.url().required() })
.array()
.maxItems(5),
// read type: { label: string; url: string }[] | nullThe operator set changes with it. The eleven object operators are gone and the
array set takes over: containsAll, containsAny, eq, length, isEmpty,
isNotEmpty, isNull, isNotNull. The types offer a ninth, contains, with
no operator behind it. It falls through to an ILIKE against the jsonb
column, which Postgres rejects. See Arrays.
Localization
.localized() on the object itself moves the whole jsonb value into the i18n
table, one stored object per locale.
.localized() on a single nested key splits instead. QUESTPIE walks the nested
fields, leaves a marker at that path in the structure column, and stores the
value per locale. It recurses through nested objects and through .array(),
where every item is split at the same path.
profile: f.object({
name: f.text().required(), // one value for every locale
bio: f.textarea().localized(), // one value per locale
}),The outer call wins
Mark the object .localized() and the per-key split is skipped entirely, so a
nested .localized() below it does nothing. Pick one level.
Access on nested keys
A nested .access() rule registers under its dot path, address.zip. Reads
evaluate the rule and delete the key from the response. Writes evaluate it per
key present in the payload, and a denied key throws instead of being ignored.
Both recurse into deeper objects and into array items. Both are skipped for a
system-mode call, which is how seeds and internal code still see the key. See
Access control.
Admin
Both methods below come from @questpie/admin, which registers them as field
extensions. They sit on the chain of every field, and only when that module is
enabled. See Collections and Globals.
.admin(config) sets the panel's shape:
| Option | Values | Default |
|---|---|---|
wrapper | "flat", "collapsible" | "collapsible" |
layout | "stack", "inline", "grid" | "stack" |
columns | number, read by grid only | 2 |
defaultCollapsed | boolean, read by collapsible | true |
.form(({ f }) => ({ fields })) orders the keys instead, and accepts the same
section and tabs containers the collection form takes. Here f is a name
proxy, so f.street is the string "street".
With a form set, layout and columns are never read. Each section carries
its own pair, defaulting to "stack" and 2. wrapper and defaultCollapsed
still apply.
address: f
.object({ street: f.text(), city: f.text(), zip: f.text(10) })
.admin({ defaultCollapsed: false })
.form(({ f }) => ({
fields: [
{ type: "section", label: "Postal", layout: "grid", fields: [f.zip, f.city] },
],
})),Related
Select field
f.select() takes a list of options, or a handler that produces one. A static list narrows the read type to the literal union of its values and derives a z.enum from them.
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.