Number field
One factory over six Postgres numeric types. The mode you pass picks the column and decides whether the derived schema demands a whole number.
Signature
| Call | Column | Derived schema |
|---|---|---|
f.number(), f.number("integer") | integer | z.number().int() |
f.number("smallint") | smallint | z.number().int() |
f.number("bigint") | bigint | z.number() |
f.number("real") | real | z.number() |
f.number("double") | double precision | z.number() |
f.number({ mode: "decimal", precision?, scale? }) | numeric(precision, scale) | z.number() |
precision and scale fall back separately, to 10 and to 2, so
f.number({ mode: "decimal", precision: 18 }) gives you numeric(18, 2).
.int() reaches the schema for integer and smallint only. bigint is not
in that pair, so a fractional value passes validation there. Chain .int()
yourself on any mode where you want whole numbers.
Every mode reads and writes a plain JS number. bigint and decimal are the
two Drizzle column builders that could hand you something else, so QUESTPIE
passes them mode: "number". The other four take no such option.
import { collection } from "#questpie/factories";
export const products = collection("products").fields(({ f }) => ({
stock: f.number().required(),
rating: f.number("smallint").min(1).max(5),
price: f.number({ mode: "decimal", precision: 10, scale: 2 }),
}));`bigint` mode is a 53-bit number
Drizzle maps it to its int53 column builder, so a value past
Number.MAX_SAFE_INTEGER loses precision on read. decimal comes back
through Number() too, so arithmetic on it is floating point.
Value refinements
Five methods on top of the modifiers every field has. Each returns a new field, so they chain in any order.
| Method | Schema | Admin |
|---|---|---|
.min(n) | .min(n), inclusive | the input's min |
.max(n) | .max(n), inclusive | the input's max |
.positive() | .positive(), strictly above zero | nothing |
.int() | .int() | nothing |
.step(n) | refines value % n === 0 | nothing |
All five apply to the derived schema on create and update. Only .min() and
.max() carry on to the admin control.
collection("orders").fields(({ f }) => ({
quantity: f.number().required().min(1),
discount: f.number("real").min(0).max(100),
}));`.min()` and `.max()` bound the value here
The same two names on f.text() bound string
length instead. One pair of names, two pieces of state, picked by the type of
field you put them on.
`.step()` is a JS remainder
The check is value % n === 0, so a fractional step inherits float error.
.step(0.1) rejects 0.3, because 0.3 % 0.1 is 0.09999999999999998.
Whole steps are exact.
Filtering
A number field carries the number operator set, so the operator object in its
where entry types to exactly these eleven keys.
| Operator | Operand | Matches |
|---|---|---|
eq, ne | number | Equal, not equal |
not | number | null | Not equal, or IS NOT NULL when you pass null |
gt, gte | number | Above, at or above |
lt, lte | number | Below, at or below |
in, notIn | number[] | In the list, not in the list |
isNull, isNotNull | boolean | Null check, inverted when you pass false |
A bare value is the shorthand and types alongside that object. { stock: 5 }
compiles to eq, { stock: null } to IS NULL.
const { docs } = await app.collections.products.find({
where: {
stock: { gt: 0 },
price: { gte: 10, lte: 50 },
},
});In the admin
The control is an <input type="number">. List cells print through
toLocaleString() in tabular figures, and a null shows as -.
.min() and .max() arrive as the input's own bounds, and the control clamps
what you type back into range before the form sees it.
.admin(config) sets the rest of the control. It appears on the field once
@questpie/admin is enabled, and its generated signature takes unknown, so
nothing type-checks the keys you pass.
| Key | Type | Default | Effect |
|---|---|---|---|
placeholder | string | none | Placeholder text |
showButtons | boolean | false | Wraps the input in minus and plus buttons |
step | number | 1 | The input's step, and how far a button moves |
.step(n) on the field is validation only and never reaches the control, so set
step here as well when you want the two to agree.
Keys such as showInList, listWidth, sortable and filterable come from
the base admin config that every field type shares.
Lists of numbers
.array() turns the column into jsonb and swaps the operator set. Every
comparison above goes, gt and lte and in with them, and the eq that
survives takes the whole list. .minItems(n) and .maxItems(n) bound it. The
admin drops the number control for a numbered list of plain number inputs, so
the clamping and the buttons go too.
scores: f.number().array().maxItems(10),Arrays has the operator table that replaces this one.
Types
A number field contributes number to the row, the insert and the where.
.required() makes it non-null and mandatory on insert. .default(0) makes the
input optional and type-checks the literal against number.
type Product = typeof products.$infer.select;
// ^? { id: string; stock: number; rating: number | null; ... }import type { CollectionDoc, CollectionWhere } from "#questpie";
type Product = CollectionDoc<"products">;
type ProductFilter = CollectionWhere<"products">;Related
Fields is the table of every type and the modifiers they all share.
f.text() is the string field, where .min() and
.max() mean length.
Arrays covers .array() on any field.
Validation covers the derived schema and the .zod()
escape hatch.
Reading and writing covers the rest of the
query surface around where.
URL field
`f.url()` is a varchar whose value has to parse as a URL. The call picks the width, the width becomes the Zod cap, and the field filters with the string set plus host and protocol.
Boolean field
The two-state flag. One `boolean` column, a `z.boolean()` schema, five filter operators, and no arguments to get wrong.