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.
| Call | Column | Derived schema |
|---|---|---|
f.url() | varchar(2048) | z.string().url().max(2048) |
f.url(n) | varchar(n) | z.string().url().max(n) |
The argument is a number and nothing else. There is no options object. There is
no unbounded mode either. The constructor argument is the only thing that sets
the column width. Only .array() and the .drizzle() escape hatch replace the
column.
import { collection } from "#questpie/factories";
export const links = collection("links").fields(({ f }) => ({
website: f.url().required(), // varchar(2048), NOT NULL
webhook: f.url(500), // varchar(500)
}));Without .required() the field selects as string | null. With it, string.
What passes
The check hands the value to new URL(). Anything that parses passes. QUESTPIE
adds no scheme or hostname constraint, so the field is not limited to http and
https.
| Value | Result |
|---|---|
https://example.com/a?b | Passes |
mailto:hi@example.com | Passes, any scheme parses |
ftp://host/file | Passes |
example.com | Rejected, no scheme |
//example.com | Rejected, no scheme |
" https://x.dev " | Passes, stored trimmed |
That last row is a write, not just a check. The parser trims the value and hands
the trimmed string back. create() and update() then store what the schema
returned. Nothing else is normalized. Case, port and trailing slash are stored
exactly as you typed them.
Refine with .zod() to narrow the scheme. The field type carries no protocol
option.
endpoint: f.url().zod((s) => s.refine((v) => String(v).startsWith("https://"))),.zod() receives the schema typed as ZodType. So the refinement's argument
arrives as unknown. Narrow it yourself. The refinement runs after the format
and length checks, never instead of them.
Methods
Two methods come with the type. The base chain works here as on any field, so
.required(), .default(), .label() and .localized() all apply.
| Method | Effect |
|---|---|
.min(n) | Adds z.string().min(n), a character count |
.max(n) | Adds z.string().max(n), a character count |
endpoint: f.url(500).required().min(12),`.max()` only tightens
The constructor bakes its width into the schema. .max(n) adds a second check
beside it. f.url(500).max(2000) still rejects 600 characters. The column
stays varchar(500). To widen, change the constructor argument.
Filtering
URL carries urlOps, the fourteen string operators plus three of its own.
| Operator | Operand | Matches |
|---|---|---|
eq / ne | string | Equal, not equal |
not | string, or null | Not equal, or IS NOT NULL when given null |
in / notIn | string[] | In the list, not in the list |
like / notLike | string | LIKE, case-sensitive, you write the % |
ilike / notIlike | string | The same, case-insensitive |
contains | string | Substring, the % are added for you |
startsWith | string | Prefix |
endsWith | string | Suffix |
isNull / isNotNull | boolean | Pass false and each inverts |
The three additions:
| Operator | Operand | Compiles to |
|---|---|---|
host | string | col ILIKE '%://value%' |
hostIn | string[] | The same, one arm per value, joined with OR |
protocol | string | col LIKE 'value://%', case-sensitive, anchored |
const { docs } = await app.collections.links.find({
where: { website: { host: "questpie.com" } },
});`host` and `protocol` are patterns, not a parsed URL
host looks for :// plus your value anywhere in the string. So
example.com also matches https://example.com.evil.net. It matches any URL
carrying that text in its query too. protocol needs the //, so mailto
never matches. An empty hostIn compiles to FALSE.
In the admin
The form control is TextField. The list cell is TextCell. It truncates the
text and puts the full value in a title attribute. Neither renders a link.
What crosses to the browser:
- The input is
type="text". The primitive does supporttype="url"and the URL touch keyboard that goes with it. The registration asks for neither. maxLengthbecomes the input'smaxLengthattribute. Typing stops there..max(n)overwrites that number, even when it sits above the constructor width.- The admin rebuilds its own schema for the form. It uses
z.url()andmaxLengthonly. It never readsminLength, so.min(n)is a server-side check. Text takes the generic path instead, which applies both bounds.
Configure the rest with .admin(). @questpie/admin registers that field
extension.
| Key | Type | Effect |
|---|---|---|
placeholder | string | The input's placeholder |
showProtocolDropdown | boolean | Nothing. Declared only |
defaultProtocol | "http" | "https" | Nothing. Declared only |
The last two type-check and reach the component as props. No component under
packages/ reads either one. The shared .admin() keys behave as they do on
any field. That covers hidden, readOnly, group, colspan and the rest.
Lists
.array() replaces the column with jsonb. It also swaps urlOps for the
multi-value set. So host and protocol are gone from the filter. Each element
is still parsed as a URL on write.
mirrors: f.url().array().maxItems(5),The admin's item-type allowlist has no url in it. Items get a plain text
input. The form runs no per-item check. The write catches a bad element.
Related
Text is the plain string. It owns the stringOps
set that URL extends.
Email is the other format-checked varchar. It has
domain and domainIn in place of host, hostIn and protocol.
Arrays covers what .array() does to reads, writes
and filters.
Uploads is for a file you store rather than a link you point at.
Validation covers the derived schema and .zod().
Email field
`f.email()` is a varchar with a format check on it. One argument sizes the column, and the type extends the string operator set with two domain matchers.
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.