Stability: Stable — see Stability Tiers.
Quereus implements a logical type system that separates type semantics from physical storage representation. This design provides strict type safety and extensibility while maintaining runtime performance.
- Logical vs Physical Separation: Types define validation and comparison semantics (logical) while values are stored using a small set of physical representations
- Strict Typing: All type checking is strict - no implicit coercion between incompatible types
- Type-Specific Collations: Collations are associated with specific types (primarily TEXT-based types)
- Plugin Extensibility: Custom types can be registered via plugins
- Performance First: Type information enables optimized comparisons without runtime type detection
- Collations: Type-specific. TEXT types support BINARY/NOCASE/RTRIM; numeric and temporal types have natural ordering
- Type Enforcement: Always strict - values must match declared types or be explicitly converted via conversion functions
- Type Conversion: Use functions like
integer(),text(),date()instead of CAST syntax (though CAST is supported for compatibility) - Date/Time: Native DATE, TIME, DATETIME types using Temporal API internally, stored as ISO 8601 strings
- JSON: Native JSON type with
PhysicalType.OBJECT— values stored as JS objects in memory, serialized to JSON strings on disk - Constraints: Length, precision, and other restrictions handled via CHECK constraints, not type definitions
Physical types represent how values are stored in memory and on disk:
export enum PhysicalType {
NULL = 0,
INTEGER = 1, // number | bigint
REAL = 2, // number (floating point)
TEXT = 3, // string
BLOB = 4, // Uint8Array
BOOLEAN = 5, // boolean
OBJECT = 6, // object (for JSON, custom types)
}
export type SqlValue = string | number | bigint | boolean | Uint8Array | JsonSqlValue | null;
// JsonSqlValue = { [key: string]: JSONValue } | JSONValue[]Logical types define the semantics and behavior of values:
export interface LogicalType {
// Identity
name: string; // e.g., "DATE", "INTEGER", "TEXT"
physicalType: PhysicalType; // Physical storage representation
// Validation
validate?(value: SqlValue): boolean; // Check if value is valid for this type
parse?(value: SqlValue): SqlValue; // Convert/normalize value to canonical form
// Comparison
compare?(a: SqlValue, b: SqlValue, collation?: CollationFunction): number;
supportedCollations?: readonly string[]; // Which collations apply to this type
// Serialization
serialize?(value: SqlValue): SqlValue; // Convert for storage/export
deserialize?(value: SqlValue): SqlValue; // Convert from storage
// Metadata
isNumeric?: boolean;
isTextual?: boolean;
isTemporal?: boolean;
// Sargable-range support (optional)
// For monotone-but-lossy transforms (e.g. `date(ts) = D`), compute the
// half-open range `[lowerInclusive, upperExclusive)` on the input value.
// `kind` is named by the function schema's `rangeRewriteOnArg` trait;
// see docs/optimizer-rule-families.md § "Sargable range rewrites".
bucketBounds?(
kind: string,
value: SqlValue,
): { lowerInclusive: SqlValue; upperExclusive: SqlValue } | undefined;
}Columns reference logical types:
export interface ColumnSchema {
name: string;
logicalType: LogicalType;
notNull: boolean;
primaryKey: boolean;
defaultValue: Expression | null;
collation?: string; // Must be in logicalType.supportedCollations
// ... other fields
}Plan nodes use ScalarType which includes the logical type:
export interface ScalarType {
typeClass: 'scalar';
logicalType: LogicalType;
nullable: boolean;
collationName?: string;
/** Provenance of collationName: 'explicit' | 'declared' | 'default' (absent = 'default'). */
collationSource?: CollationSource;
isReadOnly?: boolean;
}This ensures type information flows through the entire planning and execution pipeline.
INTEGER
- Physical:
PhysicalType.INTEGER - Values:
number(safe integers) orbigint - Comparison: Numeric ordering
- Collations: None
- Text conversion (
cast('…' as integer),integer('…'), and the plan-time cast the planner inserts for a numeric-vs-text comparison) reads the leading integer run and returns anumberwhile it stays a safe integer, an exactbigintbeyond that — the same boundary the lexer applies to INTEGER literals. There is no 64-bit clamp:cast('99999999999999999999999999' as integer)is exact rather than SQLite'sINT64_MAX, matching Quereus' arbitrary-precision integer literals.
REAL
- Physical:
PhysicalType.REAL - Values:
number(floating point) - Comparison: Numeric ordering with NaN handling (NaN sorts smallest, NaN = NaN). A
bigintoperand is tolerated even though it is outside REAL's value space — the shared index/PK comparators hand through raw storage-class values, so arealcolumn compared against an integer literal past 2^53 arrives as one — and is ordered by exact mathematical value, not rounded throughNumber() - Collations: None
NUMERIC (SQLite's NUMERIC affinity — integer if it fits, else real)
- Physical:
PhysicalType.REAL - Values:
numberorbigint— both halves are accepted byvalidate/parse, so a NUMERIC column can hold a whole number past 2^53 in exactbigintform, including one written as text (insert into t(numeric_col) values ('9007199254740993')). Only an all-digit string takes that integer arm; a fractional spelling ('9007199254740993.0') falls through to REAL and rounds, as in SQLite - Comparison: numeric ordering with REAL's NaN handling (NaN sorts smallest, NaN = NaN).
Mixed
number/bigintpairs are ordered by exact mathematical value — REAL and NUMERIC share one comparator (compareNumericWithNaN,types/builtin-types.ts) - Collations: None
BOOLEAN
- Physical:
PhysicalType.BOOLEAN - Values:
boolean(true/false) - Comparison: false < true
- Collations: None
A numeric index key's identity is its value, not the JavaScript representation
(number vs bigint) that happens to hold it. Three independent layers implement that
identity and must never disagree:
- the hash/set membership key —
canonicalNumericinsrc/util/key-serializer.tsputsnumber,bigintandbooleanunder onen:tag and routes integer-valued numbers throughBigInt(n), so5,5.0and5nall serialize ton:5; - the in-memory index and primary-key BTrees — ordered by the column's declared
logical type
compare;INTEGER,REALandNUMERICall rank a mixednumber/bigintpair by exact magnitude (compareNumericValues); - the persistent store's key bytes —
encodeNumericin@quereus/store'scommon/encoding.tsuses a single numeric tag with an exact residual tie-break, so integers and reals interleave by magnitude:5nand5.0encode identically,9007199254740993nand9007199254740992do not.
Because all three agree, INTEGER, REAL and NUMERIC are one seek key space: an
index seek keyed by a value of one may be issued against a column declared another
without missing a row. sharesSeekKeySpace (src/types/builtin-types.ts) is the
plan-time predicate; rule-key-set-seek and the index-nested-loop candidate builder both
gate on it. Two consequences worth stating explicitly:
- The key value is never coerced. Converting it into the target column's type would
truncate (
INTEGER_TYPE.parse(1.5)→1) and mint a key for a value=calls unequal — a wrong answer wherever no residual re-check survives above the seek. BOOLEANis not in the space, even though the key serializer and the store's byte encoding both fold booleans into it.BOOLEAN_TYPE.compareranks bya === b, so a memory BTree over a boolean column is ordered by a comparator the probe side does not share. Plugin-registered numeric types are excluded for the same reason: they supply their owncompare.
TEXT
- Physical:
PhysicalType.TEXT - Values:
string - Comparison: Collation-based
- Collations: BINARY (default), NOCASE, RTRIM, custom
BLOB
- Physical:
PhysicalType.BLOB - Values:
Uint8Array - Comparison: Byte-by-byte
- Collations: None
DATE
- Physical:
PhysicalType.TEXT(ISO 8601 string: "YYYY-MM-DD") - Values: ISO date strings
- Validation: Must parse as a valid bare PlainDate, or as a datetime string (bare, offset,
Z, or[zone]) from which a date can be extracted - Comparison: Lexicographic (ISO strings sort correctly)
- Collations: None
- Canonicalization: A datetime-shaped input is first converted to UTC (offset /
Z/[zone]annotations honored), then the UTC date is stored. Numeric inputs (Unix milliseconds) are likewise canonicalized through UTC.
TIME
- Physical:
PhysicalType.TEXT(ISO 8601 string: "HH:MM:SS.sss") - Values: ISO time strings
- Validation: Must parse as a valid bare PlainTime, or as a datetime string (bare, offset,
Z, or[zone]) from which a time can be extracted - Comparison: Lexicographic
- Collations: None
- Canonicalization: A datetime-shaped input is first converted to UTC, then the UTC wall-clock time is stored —
'2024-01-15T10:30:00+02:00'stores as'08:30:00', not'10:30:00'.
DATETIME
- Physical:
PhysicalType.TEXT(ISO 8601 string: "YYYY-MM-DDTHH:MM:SS.sss") - Values: ISO datetime strings
- Validation: Must parse as valid Temporal.PlainDateTime, Temporal.ZonedDateTime, or Temporal.Instant
- Comparison: Lexicographic (by UTC wall-clock — see canonicalization below)
- Collations: None
- Canonicalization: Inputs with an offset (
+HH:MM/Z) or[zone]annotation are converted to UTC, and numeric inputs (Unix milliseconds) are canonicalized through UTC, before being stored as the bare PlainDateTime form. Equal instants compare equal regardless of input shape.
TIMESPAN
- Physical:
PhysicalType.TEXT(ISO 8601 duration string: "PT1H30M", "P1DT2H") - Values: ISO 8601 duration strings
- Validation: Must parse as valid Temporal.Duration
- Comparison: Total duration comparison (normalized to seconds; calendar units — years/months/weeks — resolve against a fixed reference date, 2024-01-01)
- Ordering: Semantic (
semanticOrdering: true) — ordered by elapsed time, not by duration text:'PT90M'sorts before'PT2H'although the text sorts the other way. See "Semantic ordering" below. - Identity:
'PT1H'and'PT60M'are the same elapsed time —=treats them equal, and DISTINCT / GROUP BY / set operations / hash-join keys collapse them (via the type'sgroupKeyhook, which maps compare-equal values to one hash representative). Which textual representative survives is unspecified. - Collations: None
- Arithmetic: Supports addition/subtraction with DATE, TIME, DATETIME types
- Human-readable parsing:
timespan('1 hour 30 minutes')→"PT1H30M"
Two families of date/time functions, typed differently. The single-argument
conversion functions date(x), time(x), datetime(x) (func/builtins/conversion.ts)
return DATE / TIME / DATETIME and produce each type's canonical spelling. The
modifier-accepting variadic forms date(x, …), time(x, …), datetime(x, …)
(func/builtins/datetime.ts) return TEXT: they emit SQLite's display spelling,
which is not canonical — datetime() separates date and time with a space rather than
T, and time(…, 'subsec') always emits three fractional digits where TIME trims them.
Declaring the temporal type on those would make the write path treat the value as already
in declared form (see "Where coercion happens (and why exactly once)") and store the
display spelling into a temporal column, where comparison is binary text — so the row
would stop matching canonically-written values. Reconciling the two families is tracked by
debt-variadic-datetime-functions-not-temporally-typed.
NULL
- Physical:
PhysicalType.NULL - Values:
nullonly - Used for expressions that always return NULL
JSON
- Physical:
PhysicalType.OBJECT - Values: Native JS objects, arrays, and JSON-compatible primitives (stored in memory as-is)
- Validation: Must be valid JSON; accepts objects, arrays, numbers, booleans, strings (parsed as JSON), and null
- Comparison: Deep structural comparison (
deepCompareJson). Object key order is not significant —{a:1,b:2}equals{b:2,a:1}— but array element order is (positional). Numeric storage class holds, so a JSON number scalar5equals5.0. A JSON string scalar always compares as text — under the supplied collation, or BINARY (code-point order) when none is given — so the strings"9"and"9.0"are distinct values and never collapse onto one row. - Comparison against SQL text: JSON's values are native JS objects, a storage class that never compares equal to a string, so a text operand is converted at plan time —
insertCrossTypeCoercion(planner/building/coercion.ts) wraps the non-object side of a comparison / BETWEEN bound / IN-list value / simple-caseWHEN incast(… as json).json_col = '{ "a" : 1 }'therefore matches a row stored as{"a":1}, and an unhinted bound parameter (plan-time type TEXT) comes along for free. An IN whose right-hand side is a subquery has no fixed operand list to wrap, so it converts per row inside membership evaluation instead (inMembershipKeysinruntime/emit/subquery.ts, using the same lenient-cast semantics viatypes/cast-semantics.ts):json_col in (select text_col from …)andtext_col in (select json_col from …)both match structurally. That conversion is asymmetric — only the non-JSON side converts, never the JSON side, so a JSON string scalar (physically a plain string, e.g. the document"[1,2]"stored as the string[1,2]) is not re-parsed into a different document. Acol in (subquery)that decorrelates into a semi / existence-flag join has the=that rewrite synthesizes reconciled the same way (both arms callcoerceComparisonSet). The gate isphysicalType === OBJECT, notsemanticOrdering— the temporal types are physically text and keep their existing runtime path. The cast is lenient: text that is not valid JSON source still compares unequal rather than erroring, sojson_col = 'not json'is false. It gets there two ways — a bare string is itself a valid JSON string scalar, so the JSON type accepts it, the operand survives the cast, and the comparison is unequal; anything the JSON type does not accept at all (a blob, say) casts to NULL instead, which makes the comparison UNKNOWN, so=and<>both match no rows. One consequence for JSON string scalars, which are physically plain strings: a column holding"hello"matches both'"hello"'and the bare'hello'. - Ordering: Semantic (
semanticOrdering: true) — ORDER BY and</>on a declared JSON column rank by the structural deep-compare (JSON type rank: null < boolean < number < string < array < object, then element/key-wise recursion — so{"a":2}sorts before{"a":10}), not by canonical JSON text. Equality is identical under both forms, so identity paths (DISTINCT, GROUP BY, hash keys) need no change. See "Semantic ordering" below. - Keys: hash keys (GROUP BY / DISTINCT / join partitioning) derive from a canonical text form (
canonicalJsonString— recursive object-key sort, arrays positional) so a value's key always agrees with the comparator: reorder-equal objects group/de-dup/conflict as one, distinct objects never over-merge. Persisted byte keys (a JSON PK / index member inquereus-store) instead encode a structural byte form (jsonStructuralKey,quereus-store's json-key.ts) — same identity, and its memcmp order also reproduces the structural compare, so the store scans JSON keys incompareorder. Both forms are used only to derive keys — never for storage or display. The canonical text form also fingerprints object-valued literals for scalar CSE (planner/analysis/expression-fingerprint.ts), so two distinct documents are never folded into one shared computation. The memory module keeps documents as native values and orders its BTree with the sameJSON_TYPEcomparator</>/ORDER BY use, so an indexed range seek walks exactly the window the operators evaluate. (The canonical-text form's own order sorts by JSON punctuation and does not reproduce the structural compare — it is never used to order a JSON index; identity is all it provides.) - Collations: None
- Serialization:
serialize()converts to JSON string for storage;deserialize()parses back to native object. Storage and display preserve insertion order (only key derivation canonicalizes) - Conversion:
json(value)parses a JSON string into a native object; inserting a JSON string into a JSON column auto-parses it - Functions: All
json_*functions accept both native objects and JSON strings as input
Some logical types define an order that observably differs from the storage-class +
collation order of their stored representation. These declare
semanticOrdering: true on the LogicalType, and the rule is:
Wherever a value of a declared logical type is ordered or compared — ORDER BY,
</>/=operators, BETWEEN, IN membership, primary-key/index order and range scans, DISTINCT / GROUP BY / set-operation identity, window ORDER BY/PARTITION BY, merge/hash join keys, UNIQUE constraint enforcement — the type'scomparefunction is the order. Text/byte order is a storage encoding detail, never a user-visible semantic.
Today the flag is set on TIMESPAN (elapsed-time order) and JSON (structural
order). DATE/TIME/DATETIME need no flag: their canonical ISO text order is their
semantic order, so the cheaper storage-class compare is already correct. ANY and
untyped expressions have no semantic-ordering type and keep storage-class +
collation ordering (their declared compare reproduces exactly that ordering, which
is why the flag — not mere presence of compare — gates routing).
A sibling flag, collationAware: true, marks the types whose compare applies the
collation function it is handed — TEXT and ANY. It drives the opposite decision
from semanticOrdering: not "route comparisons through compare" but "key declared
structures under the column's collation". A key structure built over a column
(memory PK/index BTrees via createTypedComparator, the persistent store's key
bytes, the isolation overlay's shadow keys — resolved through pkKeyCollationName)
keys a collation-aware column under its declared COLLATE, so k any collate nocase
enforces and orders case-insensitively everywhere; a collation-blind type (JSON, the
temporals — whose compare is not the generic storage-class + collation comparison:
the temporals ignore the argument, JSON ranks structurally and applies the collation
only to a string-scalar pair) keys hard-BINARY regardless.
Creating an index therefore never changes a query's answer for either kind.
The flag keys on the declared logical type of the column/expression, not the
runtime value: an ANY column holding a duration-shaped string still orders as text.
When only one side of a comparison is declared (e.g. timespan_col > 'PT90M' with a
plain text literal), the runtime temporal check in the generic comparison path still
compares durations semantically; the typed fast path engages when both sides share
the semantic-ordering type. JSON reaches that shared-type shape a different way — it
has no runtime escape hatch (its values are objects, not text), so the undeclared
side is cast to JSON at plan time instead, and the typed path then engages normally.
See the JSON entry above. Probes of a different storage class (an integer literal
against a TIMESPAN column) order by storage class and never falsely compare equal
(createTypedComparator's mismatch fallback).
The comparison builtins follow the rule through a schema declaration: nullif,
greatest and least mark the argument positions they compare as one group
(BaseFunctionSchema.comparesArgs), which drives the same object-physical
coercion =/IN/simple CASE apply and an emit-time comparator bound through the
shared collation lattice — so nullif(d, 'PT120M') matches exactly when
d = 'PT120M' does, and greatest/least rank a TIMESPAN or collated-TEXT group
the way ORDER BY would. A greatest/least group whose operands do not all
declare one type (a TIMESPAN column against a bare text literal) routes through
the same generic path a mixed > does, so the runtime duration check still
applies. Which raw value greatest/least return for values a non-BINARY
comparator ties ('PT1H' vs 'PT60M') is unspecified, the same latitude the min/max
aggregate and DISTINCT take.
All three of them return one of their arguments, so they also declare
BaseFunctionSchema.returnsArg and their coercion runs at emit time rather than
as a plan-time cast: makeComparisonGroup (runtime/emit/operand-comparator.ts)
converts a per-row copy of each argument for the comparison and the emitter hands
back the raw argument. Both paths make the identical conversion (a plan-time
CastNode runs the same lenientCast), so only the returned value differs —
greatest(json_col, '{"a":2}') compares structurally but returns the text literal
as written when the literal wins. Rewriting the argument instead would make the
cast's output the result, which is how least('abc', 1) used to return 0, a
value that was never an argument. A comparison builtin that returns a fresh
value leaves returnsArg unset and keeps the plan-time rewrite. Because the
arguments the planner sees are then the ones the user wrote, greatest/least
declare ANY for a group that is neither all-one-type nor all-numeric, rather
than advertising the first argument's type for a value that may not have it. A
NULL-typed argument is left out of that test — the only value it can win with is
NULL, which the declaration is nullable for anyway — so greatest(int_col, null)
still declares INTEGER.
greatest/least both skip NULL arguments, matching the min/max
aggregates and the window MIN/MAX — a NULL argument contributes nothing, and
an all-NULL (or zero-argument) call returns NULL. The result never depends on
where a NULL sits in the argument list, e.g. least(1, null, 3) is 1.
Pinned by test/logic/24-builtin-branches.sqllogic.
Join keys: the mixed-pair rule. A physical equi-join key (hash / bloom / merge)
compares with no type context, so it can only carry a pair whose two sides agree on
semantic ordering — either neither declares a semantic-ordering type, or both declare
the SAME one. A mixed pair, timespan_col = text_col, is inadmissible: = runs
its generic path's runtime duration check and matches 'PT1H' against 'PT60M', which a
raw-text hash key or merge co-walk does not. The equi-pair extractor
(planner/rules/join/equi-pair-extractor.ts) declines such a pair, demoting it to the
join's residual predicate, so the = operator's own semantics decide the match. The
cost is that a rare shape drops to nested-loop; losing rows is worse.
Declining rather than canonicalizing the key is deliberate. Merge join needs both
inputs physically sorted in its comparator's order, and a timespan side is sorted by
elapsed time while a text side is sorted by text — no single comparator merges those
two orders, so canonicalizing would fix hash join and leave merge join unsound.
Canonicalizing also introduces a false-positive hazard: TIMESPAN's groupKey returns a
number, so a timespan ↔ integer pair would hash-match values = reports unequal.
using (k) has no comparison machinery of its own: buildUsingCondition
(planner/building/select.ts) desugars it at build time into the l.k = r.k node an ON
join builds — through buildComparison (planner/building/expression.ts), the single
helper both spellings construct a comparison with — so it inherits everything hanging
off that node: plan-time cross-type coercion (a JSON column joined using a TEXT
column compares structurally), the
collation lattice, the mixed-pair rule above, and NULL handling (null = null is
UNKNOWN, so a NULL key never matches on either physical path).
One surface still does not follow the rule.
AS OF match/partition columns compare by storage class + collation. Correct for the canonical AS OF column
types (DATE/DATETIME, whose ISO text order is their semantic order), wrong for a TIMESPAN
or JSON match column. AS OF has no residual to demote into, so the join gate does not
apply. Tracked as tickets/backlog/bug-asof-match-column-ignores-semantic-ordering.
CHECK / assertion-derived equality facts follow it. check-extraction.ts lifts
cross-column mirror FDs, an equivalence class, and one-way col = expr determinations from a
declared CHECK onto the table reference itself — where predicate inference reads them
directly — so each cross-column arm carries the same semanticOrderingsAgree gate. create table ck (d timespan, s text, check (d = s)) mints nothing for the pair, and a row
('PT1H', 'PT60M') — which the CHECK legitimately accepts, since = compares the two by
elapsed time — still comes back from select … where d = 'PT1H'. A constant pin from a
CHECK (check (d = 'PT60M')) is ungated for the same reason the filter-side pin is (below).
Filter-level equality facts follow it, with one deliberate asymmetry.
extractEqualityFds (planner/util/fd-utils.ts) mints value-level claims from where
equalities, and the cross-column arm carries the same semanticOrderingsAgree gate the
join extractors do (invariant OPT-051): where d = s over a TIMESPAN d and a TEXT s
mints no mirror FDs and no equivalence class, because two surviving rows can agree on d
(same elapsed time) while disagreeing on s (distinct strings) — the two columns have no
common notion of "same value". A constant pin (where d = 'PT60M' ⇒ ∅ → d plus a
constant binding) is deliberately not gated: under the engine's identity for a TIMESPAN
column — the same identity distinct / group by / unique use — every surviving row
holds the same value, so the FD is true, and a ConstantBinding claims only that the
column compares equal to the bound value under its own comparison, not that it stores
that spelling. A consumer needing raw-value identity must not read a binding.
Hash-keyed identity (GROUP BY, window PARTITION BY, hash-join build/probe) cannot
call compare pairwise, so a semantic-ordering type whose stored form is not
canonical for equality also supplies groupKey — a canonical representative such
that compare-equal values serialize to the same hash key (TIMESPAN maps to total
seconds against the same fixed reference date compare uses). JSON needs no
groupKey: canonical-text equality and structural equality coincide.
IN is an identity test, so it routes through groupKey rather than compare: when
either side declares a semantic-ordering type, emitIn normalizes the probe and every
RHS value before comparing, so d IN ('PT120M') matches a 'PT2H' row exactly as
d = 'PT120M' does. Normalizing (instead of dropping compare into the membership
BTree's comparator) is what keeps the set structures sound — the normalized keys rank
by plain storage-class order, which stays total even when a list literal is not a valid
value of the type, whereas TIMESPAN.compare mixes elapsed-time and text ordering
there and is not.
UNIQUE enforcement collapses the same identity on every backend. A constrained
column whose declared type carries semantic ordering is compared through that type's
compare; every other column keeps the storage-class + collation comparison (a
TEXT/ANY column's declared compare honors the collation it is handed —
collationAware — and is equivalent to the generic path, so the cheaper generic
comparator is used; the hasSemanticOrdering flag is the gate). The
per-column comparators are built once per constraint check by the shared
uniqueEnforcementComparators (schema/unique-enforcement.ts), which the memory
backend's three re-validators, the persistent store's finders, the isolation
overlay's merged-view search, and the covering materialized view's shared candidate
generator (lookupCoveringConflicts — see mv-constraints.md)
all call, so the backends cannot drift. Concretely, in a
d timespan unique column an insert of 'PT60M' after 'PT1H' raises a UNIQUE
violation, insert or ignore drops it, and insert or replace evicts the existing
row — the same on memory and store.
insert … on conflict (<cols>) do update / do nothing routes on that same identity.
The virtual table reports the conflicting row but not which constraint fired, so the
DML executor decides which on conflict clause a violation belongs to by comparing
the proposed and existing rows at the clause's target columns — through comparators
built by the same uniqueEnforcementComparators (per-column enforcement collations
resolved at plan time by resolveConflictTargetEnforcement in
planner/building/insert.ts, comparators built once at emit in
runtime/emit/dml-executor.ts). So on conflict (d) do update fires for a re-spelled
TIMESPAN duration rather than aborting with a UNIQUE error, while a NOCASE/RTRIM
column keeps routing by its collation. One residual corner is unfixable by value
comparison and stays out of scope: an insert violating the targeted constraint and
another one at once is suppressed by the matching clause, because the vtab
short-circuits on the first violation and never reports the second.
The persistent store follows the same rule for both identity and order (resolved by
quereus-store's storeSemanticKeyTransform): a TIMESPAN key member is encoded
through groupKey, so 'PT1H' and 'PT60M' collide on one physical key — duplicate
spellings raise the ordinary PK/UNIQUE violation, on conflict actions fire, and the
isolation overlay shadows across spellings; a JSON key member is encoded in a
store-local structural byte form (jsonStructuralKey, quereus-store's
json-key.ts) whose memcmp order reproduces the structural compare — so a store scan
emits JSON keys in compare order, agreeing with the memory backend, and the
isolation overlay aligns its merge streams (an in-transaction update or delete of a
JSON-keyed row shadows correctly). With both types' key bytes order-faithful and
identity-faithful, the store reads such columns through their key bytes rather than
by scanning: it advertises PK order over TIMESPAN/JSON key members (order by <pk>
elides its Sort), serves a leading-column range predicate as a byte-window seek,
and serves an equality as a point read — a full-PK where d = 'PT60M' fetches the
single key the row stored as 'PT1H' occupies, and where j = json('{"b":2,"a":1}')
the one stored as '{"a":1,"b":2}'; the same holds for a secondary index whose
leading columns are such types.
Two gates govern all of it: an explicit per-type allow-list
(semanticKeyOrderIsFaithful, quereus-store's pk-key-resolution.ts — a claim about
the values a table can hold, deliberately not inferred from a transform's mere
existence) and a per-value probe gate (semanticProbeIsKeyFaithful) on each seek
probe, since nothing coerces a query value to the column's declared type. A probe
with no faithful byte position (a numeric or unparseable TIMESPAN probe, a
blob/bigint JSON probe) degrades in whichever way that arm can afford, and the
type-aware residual decides every row either way:
- a range bound is dropped, which only widens the window back toward a full scan;
- a full-PK equality declines the whole point arm — a point window is a single byte position and cannot be widened, only under-fetched;
- a secondary index's EQ prefix stops short at that column — a window over the columns before it is a strict superset, which the residual then narrows.
(An unpaired surrogate inside a JSON probe is deliberately not degraded: it raises,
matching the rule a text primary key's seek bounds already carry.) IN-list
multi-seeks are the one shape still declined outright — their merged windows are the
whole access, with none of the three degradations available — tracked as backlog
feat-store-semantic-key-multiseek.
The min/max aggregates follow the same rule: at emit (and materialized-view
plan-build) time the call site binds the aggregate to its argument's declared type
and resolved collation (AggregateFunctionSchema.bindArgs, applied via
bindAggregateSchema), replacing step/merge/decode/finalize with closures over the
argument's semantic comparator. So min(timespan_col) returns the shortest
duration, min(json_col) the structurally-least document, and min over a
collate nocase column the NOCASE-least value — each agreeing with
order by … limit 1 — and store-maintained materialized-view min/max columns
(delta merge and read-side rollup both execute the bound algebra) agree with
direct evaluation. Untyped/ANY arguments with no declared collation keep the
storage-class + BINARY behavior. Under a semantic tie with byte-different
spellings ('PT1H' vs 'PT60M'), which raw value survives is unspecified — the
same latitude DISTINCT and GROUP BY take for a group representative.
An aggregate's result type carries its argument's logical type but not the
argument's collation, so a materialized view over min(nocase_col) has a
BINARY-declared backing column. Anything that re-ranks stored partials must
therefore take the collation from the argument, not from the backing column — the
read-side rollup does so via the collation the rewrite matcher records alongside
each stored partial (MergeReagg.argCollation).
Window min(x) over (…) / max(x) over (…) follow the same rule through the
same seam. Window functions live in their own registry
(schema/window-function.ts), so it carries its own bindArgs hook — taking the
same AggregateArgBinding and routing through the same
createSemanticValueComparator — applied by bindWindowSchema where
runtime/emit/window.ts resolves each call site's schema. The window emitter has
three execution shapes (the buffered frame walk, the streaming running
accumulator, and the streaming sliding-frame scan) and all three fold through
that one bound schema, so they cannot rank differently for the same query.
Values are validated at INSERT/UPDATE boundaries:
export function validateValue(value: SqlValue, type: LogicalType): SqlValue {
if (value === null) return null;
// Type-specific validation
if (type.validate && !type.validate(value)) {
throw new QuereusError(
`Type mismatch: expected ${type.name}, got ${typeof value}`,
StatusCode.MISMATCH
);
}
// Type-specific parsing/normalization
if (type.parse) {
return type.parse(value);
}
return value;
}A write's values are converted to the declared column logical types once, at the top of the DML pipeline — in the DML emitters — and everything downstream (constraint checking, the isolation overlay, the storage layer) sees the declared form.
Conversion cannot simply be re-run at each layer, because it is not repeatable for every type:
JSON_TYPE.parseis not idempotent for a string scalar.parse('"Bob"')returns the bare stringBob, andparse('Bob')then throwsCannot convert 'Bob' to JSON: invalid JSON syntax— while re-parsing the stored text9silently changes it into the number 9. A converted value is indistinguishable at runtime from unparsed JSON source, so "convert again just in case" is not safe.
What decides whether a cell converts is therefore the static type of the
expression that produced it, which the planner already knows. The rule
(buildRowCoercion in types/validation.ts): convert cell i iff the
producing expression's LogicalType is not — by object identity — the target
column's type. A SQL literal '"abc"' is TEXT → into a JSON column, convert; a
reference to a JSON column is JSON → already declared form, leave alone.
Concretely:
emitInsertmasks each cell by the source relation's attribute type at that position (the source is projected into full table-column order, so the two align).insert into b select j from acopies JSON values untouched; a VALUES literal still converts.emitUpdatemasks an assigned column by its assignment expression's type and an unassigned column by the source attribute's type — for the ordinary target-table scan that is the declared type itself, so the carried-over stored values are never re-converted.update t set v = 'X'leaves a JSON key column byte-identical.- Two paths inject a value after that pass and convert their one cell by the
same rule: the
OR REPLACENOT NULL DEFAULT substitution (runtime/row-constraints.ts, reached both from theConstraintCheckNodeemitter and from theON CONFLICT … DO UPDATEarm's own validation) andON CONFLICT … DO UPDATEassignments (the DML executor).
The DML executor then passes preCoerced: true on its vtab.update calls, and
every conversion-performing layer below honors it: the memory module
(MemoryTableManager.performInsert/performUpdate), quereus-store's
StoreTable, and quereus-isolation's IsolatedTable (which also forwards the
flag to its overlay writes). The isolation layer's flush and tombstone writes
set the flag themselves — those cells were read back out of storage.
The storage layer's own conversion is not gone: a write that does not come
through the DML executor — external-change apply, materialized-view maintenance
writes, direct vtab.update API use — leaves preCoerced unset and the
storage layer converts as before. The public vtab contract is unchanged.
Because the row reaching ConstraintCheckNode is already in declared form,
CHECK and FK expressions — immediate and deferred alike — read it directly: a
CHECK compares the same values the read path would (check (a < b) over two
JSON columns compares structurally, not as raw text), and an UPDATE that never
mentions a JSON column shows the CHECK the stored value rather than a
re-converted (possibly damaged) one. Under OR REPLACE, the NOT NULL pass may
substitute a column's DEFAULT first; CHECK/FK then read the row as finally
substituted, which is also what flows downstream.
Conversion errors surface from the emitter (for INSERT, before the row reaches
constraint checking), but the message text is unchanged — the same
validateAndParse produces it.
ALTER backfills follow the same rule. alter table … add column … default <x>,
alter table … add column … generated always as (<x>) and alter column … set not null write existing rows outside the DML pipeline, so they convert explicitly:
- A DEFAULT that folds to a literal goes through
foldDefaultToType(types/validation.ts), which folds and converts to the new column's declared type. Every site shares it — the memory module,quereus-store, the isolation overlay's staged rows (which writepreCoerced: trueand so cannot pick the conversion up implicitly), and the batched data-change-event remaps — so a backfilled cell is indistinguishable from one a fresh INSERT under the same DEFAULT would produce. No identity guard is needed here: an AST literal is always raw source form. A literal that cannot convert (add column n integer default 'abc') raises the sameMISMATCHthe equivalent INSERT raises, and does so whether or not the table holds any rows, so DDL acceptance does not depend on the data. Note the deliberate asymmetry withCREATE TABLE, which still accepts an unconvertible literal DEFAULT and only fails at the first INSERT. - A non-foldable DEFAULT (
default (new.<col>)) is evaluated per existing row, and its result converts only when the default expression's static type is not already the new column's type — the same object-identity checkbuildRowCoercionmakes, for the same reason (add column k json default (new.j)over an existing JSON column must copy, not re-parse). The conversion runs before the per-row CHECK predicates, matchingemitInsert. - A
generated always as (<x>)expression takes that same identity-guardedcoerceTopath — oneAddColumnBackfillserves both kinds — so a backfilled generated cell holds what an INSERT computing the same expression would store (add column g integer generated always as (v || '0')over a textvstores the integer). It differs from the DEFAULT arm in one respect: it is never folded to a bulk-written literal, because a generated column has nodefaultValuea module could write, so evengenerated always as (2)is evaluated per row.
Because every JSON_TYPE.compare caller is guaranteed to hold parsed values, a
JS string reaching compare is unambiguously a JSON string scalar and is
never re-parsed: compare('9', 9) ranks the number first (number < string)
instead of calling them equal.
The rule is only as sound as the static types it reads, so an expression node that advertises a logical type it does not actually produce becomes a write-path defect. Known cases:
- A scalar function whose schema declares a JSON return type must return
native (parsed) JSON values, never serialized text — the skip rule takes the
declaration at its word. The builtins that declare JSON (
json(),json_group_array,json_group_object) all return native values;json_extractand friends declare no return type and are unaffected.
A set operation is a conversion site under this contract. Its output column
carries rows from both operands, so SetOperationNode advertises the
symmetric per-column merge of the two operand types (mergeSetOpColumnType in
planner/analysis/set-op-type-merge.ts — order-independent, so swapping the
arms cannot change the result), OR-merging nullability alongside:
- Identical logical types → that type (the overwhelmingly common case).
- Either side NULL → the other side's type — a
select nullbranch is a valid member of every type and must not poison a well-typed union. - Both builtin numeric (and differing — rule 1 already took the identical
pairs) →
NUMERIC, whatever the pair. Deliberately not CASE's "arms differ ⇒ TEXT":1 union all 2.5stays numeric. Also deliberately not theINTEGER + REAL → REALpromotion arithmetic (BinaryOpNode.generateType) and polymorphic builtins (findCommonType) use — because unlike rule 4, rule 3 converts neither branch. Arithmetic yields one value in one form, whichREALdescribes exactly; a set operation yields a stream mixing both forms (numberfrom the REAL arm,bigintfrom the INTEGER arm), and onlyNUMERIC— whose value space isnumber | bigint— describes that. ClaimingREALwas a lie the DML skip rule believed: a bigint rode it unconverted into areal-declared column, and areal-declared key then threw out ofREAL_TYPE.compare(that comparator now tolerates a bigint operand, but the type claim was still wrong). Because no branch is converted, the read side is untouched:select <big int> union all select 2.5still returns each row in its own storage class, matching SQLite. - Exactly one side object-physical (JSON today) → the object side's type,
and the construction factory (
SetOperationNode.create) wraps the other branch in a lenient CAST so it actually produces that type — the same rule and direction predicate coercion applies tojson_col = 'text'. The conversion happens in the branch, so at a DML the advertised type matches the declared column and the skip rule correctly leaves both branches' cells alone; on the read side, UNION dedup and predicates compare both branches under JSON's structural rules. - Otherwise →
ANY.ANYon a set-op output column means "no principled common type — nothing is claimed, every consumer converts": itsparseis pass-through, itscompareis storage-class + BINARY ordering, and at a DML it is never identical to a declared column type, so every cell converts. That is correct precisely because rule 5 only fires when no branch is guaranteed to already be in target form (date_col union all '2024-01-02…': the raw literal normalizes, the stored value survives idempotent re-conversion).
A branch that surfaces set-op membership flags cannot be CAST-wrapped (the
projection would flatten its flag columns into the data arity), so a rule-4
pair over such a branch stays unconverted and honestly advertises ANY
instead. AsyncGatherNode's unionAll combinator folds the same merge across
its children, so the physical rewrite of a union-all chain advertises the same
types the logical node did.
CAST is the settled case, and states the rule the others must meet: it stays
lenient — it never throws — but it never produces a value outside the type it
advertises either. When the target type's parse throws, castFallback
(types/cast-semantics.ts) applies SQLite's numeric/text/blob fallbacks (0,
0.0, String(v), UTF-8 bytes — each a valid member of its own type); for
every other target it keeps the operand only when the target type's own
validate accepts it, and yields NULL otherwise. parse reads its input as
source text, so validate is the right question to ask: a bare string is a
legitimate JSON string scalar that JSON_TYPE.parse nonetheless rejects.
Because a converting cast can produce NULL from a non-null operand,
CastNode.getType() reports nullable for a cast that changes the logical type
— except to TEXT or BLOB, which convert every non-null operand and so cannot
introduce a NULL (castCanYieldNull, same module, is the one place that table
lives). The exception matters at the write end: a not null lens column over
cast(x as text) is sound and must still deploy, while the same shape over a
temporal or JSON target is not and is correctly blocked. The emitter reads the
resolved target type back off CastNode.getType() rather than re-resolving the
name, so the plan and the runtime cannot disagree — they previously did for a
name that misses the registry but matches an affinity rule (nvarchar → TEXT),
which made a value-preserving cast read as converting and block an index seek.
Standing regression coverage for "convert exactly once" (several JSON text
scalars, across ordinary writes and the row-rewriting paths — ALTER TABLE
(add/drop/rename column, retype, add constraint, SET NOT NULL backfill),
transactions, savepoints, rollback, INSERT OR REPLACE, primary-key
relocation, index DDL) lives in test/logic/06.9.1-json-coerce-once.sqllogic and its
capability-gated sibling test/logic/06.9.1.1-json-coerce-once-index.sqllogic.
Use type conversion functions for explicit conversion:
-- Convert string to integer
select integer('123');
-- Convert timestamp to date
select date(1234567890);
-- Convert string to real
select real('3.14');
-- Invalid conversion throws error
select integer('abc'); -- Error: Type mismatch
-- Conversion functions are just regular scalar functions
select text(42); -- '42'
select boolean(1); -- true
select datetime('2024-01-15T10:30:00');Built-in Conversion Functions:
integer(value)- Convert to INTEGERreal(value)- Convert to REALtext(value)- Convert to TEXTboolean(value)- Convert to BOOLEANblob(value)- Convert to BLOBdate(value)- Convert to DATEtime(value)- Convert to TIMEdatetime(value)- Convert to DATETIMEtimespan(value)- Convert to TIMESPAN (supports ISO 8601 durations and human-readable strings)json(value)- Convert to JSON (parses JSON strings into native objects)
Note: CAST syntax is also supported for SQL compatibility, but conversion functions are preferred.
See Built-in Functions Reference for the full list of conversion functions, including json(), date/time arithmetic with modifiers, and validation functions.
- NULL Handling: NULL compares less than any non-NULL value
- Type Matching: Both values must have the same logical type
- Type-Specific Logic: Each type defines its own comparison semantics
- Collation Support: TEXT types use collation functions
export function compareTypedValues(
a: SqlValue,
b: SqlValue,
typeA: LogicalType,
typeB: LogicalType,
collation?: CollationFunction
): number {
// NULL handling
if (a === null) return b === null ? 0 : -1;
if (b === null) return 1;
// Type mismatch error
if (typeA !== typeB) {
throw new QuereusError(
`Type mismatch in comparison: ${typeA.name} vs ${typeB.name}`,
StatusCode.MISMATCH
);
}
// Use type-specific comparison
if (typeA.compare) {
return typeA.compare(a, b, collation);
}
// Fallback to default comparison
return defaultCompare(a, b, typeA.physicalType);
}All expressions in Quereus have known types at plan time — including parameters, which must be typed at prepare time (either inferred from values or explicitly declared). There is no concept of an "untyped" expression.
When the planner encounters a comparison between operands of different type categories (e.g., numeric vs textual), it inserts an explicit conversion on the appropriate operand, matching the target type. For example, integer_column = '25' becomes equivalent to integer_column = integer('25') at plan time. This keeps the runtime free of implicit coercion — both sides of every comparison have matching type categories, enabling fast-path execution.
Same-category comparisons (both numeric, both textual, etc.) require no conversion and use a direct comparison path at runtime.
One probe against many values. IN value lists, simple CASE and the scalar builtins that declare a comparison group (nullif, greatest, least) compare ONE probe expression against N value expressions. They share the = rule through comparisonGroupCoercions (types/comparison-coercion.ts) — which coerceComparisonSet (planner/building/coercion.ts) turns into plan-time casts and makeComparisonGroup (runtime/emit/operand-comparator.ts) turns into emit-time comparison keys for the value-returning builtins — so i in ('1') and case i when '1' answer exactly as i = '1' does. The probe is a single operand, so it can only be converted ONCE, which is the one place these sites differ from a plain pairwise comparison:
- A value-side cast is decided per value and never conflicts, so a mixed list (
case i when 'abc' when '1'over an integeri) gets a per-clause decision. - A probe-side cast is hoisted, so it is applied only when unambiguous. For the JSON pairing it always is (the cast is lenient, so a non-JSON textual value survives as itself). For the numeric pairing it is not —
cast('abc' as real)is0— so the probe casts only when every non-NULL value is numeric. A textual probe against a list mixing numeric and textual values (text_col in (1, 'abc')) is therefore left uncoerced and still differs from the=disjunction on the numeric member; closing that needs a per-value probe, whichINcannot express because its members share one key space.
An IN whose right-hand side is a subquery has no operand list to wrap, so the same reconciliation happens per row inside membership evaluation (inMembershipKeys in runtime/emit/subquery.ts), gated on a uniform right-hand side so it cannot pick up the mixed-list shape the plan-time helper declines.
Cross-category comparisons are resolved at plan time by wrapping the mismatched operand in a conversion function node. The conversion targets the other operand's type category (e.g., textual → numeric via integer() or real()). Users can also write explicit conversions directly:
-- Explicit conversion (always preferred)
select * from users where age = integer('25');
-- Planner inserts equivalent conversion when types are mixed
select * from users where age = '25';The planner also handles BETWEEN expressions the same way: value BETWEEN '10' AND '100' with a numeric value will have both bounds cast to the appropriate numeric type at plan time.
Type-aware comparisons enable optimized execution:
- No runtime type detection: Type is known at index/sort creation time
- Direct comparator calls: Comparator functions are resolved once and reused
- Type-specific optimizations: Each type can implement optimal comparison logic
Collations are associated with specific types:
const TEXT_TYPE: LogicalType = {
name: 'TEXT',
supportedCollations: ['BINARY', 'NOCASE', 'RTRIM'],
compare: (a, b, collation) => collation(a as string, b as string),
};
const INTEGER_TYPE: LogicalType = {
name: 'INTEGER',
supportedCollations: undefined, // No collations for numeric types
compare: (a, b) => compareNumbers(a, b),
};Schema creation validates collation compatibility:
if (column.collation && column.logicalType.supportedCollations) {
if (!column.logicalType.supportedCollations.includes(column.collation)) {
throw new QuereusError(
`Collation ${column.collation} not supported for type ${column.logicalType.name}`,
StatusCode.ERROR
);
}
}A comparison (=, !=, <, <=, >, >=, plus IN, each BETWEEN bound,
each simple-CASE WHEN clause, and the declared argument group of a comparison
builtin — nullif pairwise, greatest/least as one N-ary merge)
resolves ONE effective collation from its operands' types via a
provenance-ranked lattice (implemented once in
planner/analysis/comparison-collation.ts, shared by every plan-time
analysis and runtime emitter so the two cannot drift):
| rank | source (ScalarType.collationSource) |
does BINARY contribute? |
|---|---|---|
| 3 | explicit — a COLLATE expression |
yes (collate binary is a real demand) |
| 2 | declared — column declared with an explicit COLLATE |
yes (c text collate binary is a real preference) |
| 1 | default — defaulted column collation (session default_collation, store-module reconcile, engine BINARY default) |
no — a defaulted BINARY contributes nothing |
| — | no collationName (literals, most expressions) |
n/a |
Resolution of left <op> right:
- The highest rank present among the two contributions wins.
- If both operands contribute at that rank with different names:
- rank 3 → plan-time error:
conflicting COLLATE clauses in comparison: X vs Y - rank 2 → plan-time error:
ambiguous collation for comparison: column collations X vs Y differ; apply an explicit COLLATE - rank 1 → BINARY, silently (defaults are preferences, not declarations)
- rank 3 → plan-time error:
- Otherwise the winning contribution's name; no contributions at all → BINARY.
Resolution is symmetric: a = b and b = a always resolve identically
(and error identically). This deliberately diverges from SQLite's
left-operand precedence, in keeping with the engine's explicit-over-implicit
philosophy: a declared NOCASE column compared against a plain column is
NOCASE from either side, and genuinely ambiguous declared/explicit pairs are
errors rather than coin flips. Conflicts error even when the operands are
statically non-textual (consistent strictness; only COLLATE-wrapped
expressions can reach this case, since non-text columns reject collation
declarations).
Errors surface at the point the comparison compiles: statement prepare for queries, DML prepare for write-path scopes (CHECK enforcement, FK parent-existence checks, upsert SET, RETURNING).
FOREIGN KEY collations are validated at declaration time. A FK's enforced
comparison is parent.k = child.fk; the same lattice that resolves it at DML
prepare also runs at declaration time (CREATE TABLE / ALTER … ADD CONSTRAINT
/ ALTER … ADD COLUMN / declarative apply) over the two columns' ScalarTypes, so
a same-rank conflicting pair is rejected the moment the REFERENCES clause is
declared rather than at the first write against the child
(schema/constraint-builder.ts validateForeignKeyCollations, mirroring the
FK-builder's comparison exactly — never a re-derived name- or textuality-based
rule). It is unconditional (not gated on pragma foreign_keys): a
contradictory declaration is malformed regardless of whether enforcement is
enabled. The one residual is a forward-declared parent (the parent table
does not exist yet when the child is declared): the parent column types are not
yet knowable, so the conflict stays caught at first DML — unchanged. Reload /
importTable deliberately does not re-validate, so a legacy persisted
conflicting FK reloads without error and still surfaces at DML.
Provenance is a function of the current catalog column, not its history.
A column reaches rank 2 (declared) two ways, with identical standing: a
CREATE-time explicit COLLATE clause, OR ALTER COLUMN ... SET COLLATE
(including SET COLLATE binary — a real BINARY demand, not the absence of
one). So the same SET COLLATE NOCASE resolves identically whether the column
was originally created with or without a COLLATE clause; the rank follows the
live column schema (ColumnSchema.collationExplicit), never how the column was
first declared.
Rank-1 default provenance is session-transient. It is not persisted as a
distinct bit: the catalog and persisted DDL are fully explicit (an explicit
COLLATE for every non-BINARY collation, BINARY elided — see docs/sql.md
§ 9.2.4). So a column that got NOCASE from session default_collation, or a
store-module reconcile default, carries rank 1 in-session but reloads through
the CREATE path as rank 2 (declared), because the re-parsed COLLATE NOCASE
sets collationExplicit. This reload upgrade is fail-louder only: a
comparison that previously resolved silently (to BINARY, or to the declared
side) can only become a prepare-time ambiguous-collation error — never silently
different results — so the upgrade needs no catalog/DDL representation change.
A defaulted BINARY (and an explicit SET COLLATE binary) reloads as rank 1
because BINARY is elided from DDL — consistent with a CREATE-time
c text collate binary column, which already round-trips to rank 1. This is the
one direction where reopen relaxes rather than tightens: an in-session rank-2
collate binary operand can make a comparison an ambiguous-collation error that,
after reopen, resolves silently (the elided BINARY contributes nothing at rank
1). The "fail-louder only" guarantee above covers the rank-1→rank-2 upgrade of
a non-BINARY default; the BINARY-elision downgrade of an explicit/declared
BINARY is the documented exception, and matches CREATE-time collate binary
either way.
Related forms:
- IN —
cond IN (e1, …, en)/cond IN (subquery)merges the RHS contributions first (a rank-3/2 name conflict among elements is the same plan-time error; rank-1 conflicts merge to no contribution; a subquery contributes its output column's contribution), then resolves condition-vs-RHS through the lattice. The whole membership test runs under that ONE collation. Literal-only lists contribute nothing, so the dominant case stays condition-driven. - BETWEEN — desugars to two independent comparisons (
expr >= lo,expr <= hi); each bound resolves against the tested expression separately. Two differently-collated bounds are NOT a conflict with each other. - Simple
CASE—case x when v1 … when vndecides each match exactly asx = v1…x = vnwould, resolved per clause (like BETWEEN's two bounds, not like IN's single merged collation). Two differently-collated WHEN operands are therefore not a conflict with each other; an explicitCOLLATEon the base and a different explicitCOLLATEon one WHEN operand is the same conflict error=raises for that pair. The routing between the declared type's owncompare, storage-class comparison and the runtime duration check is shared with BETWEEN and=(runtime/emit/operand-comparator.ts), so atimespancolumn matchescase d when 'PT120M'on elapsed time and a plaintextcolumn holding duration-shaped text stays text-compared. A searchedCASE(case when <predicate>) does no comparison of its own — its WHEN is an ordinary boolean expression that already resolved through the lattice. - USING joins —
using (k)desugars to thel.k = r.kcomparison node, so it resolves through the lattice by construction, identically to the spelled-out form. The pairwise join-key surfaces (merge / bloom / asof) all resolve their key collation through the same lattice — the sibling of set operations below. - Set operations (
UNION/INTERSECT/EXCEPT/DIFF, andUNION ALL) — each OUTPUT column resolves its dedup/compare collation symmetrically across BOTH inputs' corresponding column types through the same lattice (resolveSetOpColumnCollation), rather than inheriting the left input's collation alone. The resolved collation is written into theSetOperationNode's output column/attribute types, so it governs the dedup / membership comparator and the output column'scollationName— i.e. what an enclosingORDER BYover the set operation sorts under — in lockstep (one resolution site, both readers). The winning rank propagates ascollationSource, so a nested set operation re-resolves against the inner node's output column at the correct rank, and divergence surfaces at every level. Conflict handling splits on whether the operator dedups:- DISTINCT operators (
UNION/INTERSECT/EXCEPT;DIFFdesugars to nestedEXCEPT/UNION) DO compare, so a same-rank explicit/declared name conflict in any output column is the same prepare-time error a spelled-out comparison throws (surfaced when the compound's output scope is built). UNION ALLdoes NO dedup, so a conflict is not an error — it propagates no collation forward (BINARY-equivalent), exactly as||/ CASE swallow conflicts. Rows pass through unchanged. Non-textual columns carry no collation, so resolution is a harmless no-op. (No sort-merge set-op strategy exists today; if one is ever added it MUST derive its key collation from this same resolved output-column collation.)
- DISTINCT operators (
- Propagation through non-comparison combiners (
||concat, CASE branch merge) — the highest-ranked contribution wins and keeps its provenance; equal-rank contributions with different names propagate no collation (the conflict is not an error there — those nodes don't compare — but it must not silently coin-flip; a later comparison over the result falls back to BINARY).
Plugins can define type-specific collations:
const PHONENUMBER_TYPE: LogicalType = {
name: 'PHONENUMBER',
physicalType: PhysicalType.TEXT,
supportedCollations: ['AREA_CODE', 'COUNTRY_CODE'],
compare: (a, b, collation) => {
// Custom comparison logic based on collation
},
};Plugins can register custom logical types. For the full plugin packaging and loading workflow, see the Plugin System. The examples below show the type registration portion:
// Example: UUID type plugin
export default function register(db: Database) {
return {
types: [
{
type: 'type',
definition: {
name: 'UUID',
physicalType: PhysicalType.TEXT,
validate: (v) =>
typeof v === 'string' &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v),
parse: (v) => {
if (typeof v === 'string') return v.toLowerCase();
throw new TypeError('Invalid UUID');
},
compare: (a, b) => (a as string).localeCompare(b as string),
}
}
]
};
}-- After loading UUID plugin
create table users (
id uuid primary key,
name text not null
);
insert into users values ('550e8400-e29b-41d4-a716-446655440000', 'Alice');Quereus supports polymorphic functions that work over multiple type signatures without duplicating implementations.
Functions can define type inference logic at planning time:
export interface ScalarFunctionSchema {
name: string;
numArgs: number;
// Option A: Fixed return type
returnType?: ScalarType;
// Option B: Type inference function (for polymorphic functions)
inferReturnType?: (argTypes: ReadonlyArray<DeepReadonly<LogicalType>>) => ScalarType;
// Optional: Validate argument types at planning time
validateArgTypes?: (argTypes: ReadonlyArray<DeepReadonly<LogicalType>>) => boolean;
implementation: ScalarFunc;
}Simple case: Fixed types
export const sqrtFunc = createScalarFunction({
name: 'sqrt',
numArgs: 1,
returnType: { typeClass: 'scalar', logicalType: REAL_TYPE, nullable: false }
}, sqrtImpl);Polymorphic case: Type inference
export const absFunc = createScalarFunction({
name: 'abs',
numArgs: 1,
inferReturnType: (argTypes) => ({
typeClass: 'scalar',
logicalType: argTypes[0], // Return same type as input
nullable: false
}),
validateArgTypes: (argTypes) => isNumericOrUnknownType(argTypes[0])
}, absImpl);Declaring neither returnType nor inferReturnType yields ANY_TYPE — the engine's
"unknown scalar type". This is safe but not free:
ANY_TYPEsets neitherisNumericnorisTextual, soinsertCrossTypeCoercion(planner/building/coercion.ts) leaves both operands of a comparison alone and the generic runtime comparison decides. That is the correct answer for an unknown type, but it forfeits plan-time typing and comparison specialization (thecompare-fastpath inruntime/emit/binary.ts).- So declare the real type whenever you know it. A wrong declared type is worse than
none: while the default was
REAL_TYPE, the planner believed every undeclared function returned a number and cast the other side of a comparison to REAL, somy_text_func(x) = 'abc'silently became… = 0and was always false. Database.createScalarFunction/createAggregateFunctionnever pass areturnType, so every function registered through those two lands on this default. A plugin that builds its own schema and registers it throughDatabase.registerFunctiondeclares its own.- Omitting the return type is the only accepted way to say "unknown". A
returnTypethat is present but malformed is rejected at registration with aMisuseErrornaming the function and the offending field — it is never quietly downgraded toANY_TYPE. The one contract, shared byDatabase.registerFunctionand all thecreate*helpers, lives innormalizeFunctionSchema(func/registration.ts);docs/plugins.md§ Declaring return types states it for plugin authors. - Every built-in scalar function now declares a
returnTypeor aninferReturnType, with one deliberate exception:json_extract, whose result shape depends on the data rather than on the argument types, declaresANY_TYPEexplicitly. The shared shape constants live infunc/builtins/return-types.ts(TEXT_RETURN,INTEGER_RETURN,REAL_RETURN,BOOLEAN_RETURN,BLOB_RETURN,JSON_RETURN,ANY_RETURN, plus_NOT_NULLvariants and thescalarReturn(type, nullable)builder for the types with no constant). Every built-in scalar, aggregate and window function declares through them — use them rather than re-spelling the four-field literal. They are re-exported from the package index, so plugins outside this repo can use them too. - A function whose result is not closed over its argument's type must declare the wider
type rather than infer the argument's.
sqrt,powandpowerall declare REAL for this reason:sqrt(int_col)claiming INTEGER would make the write path skip conversion and store1.4142135623730951in an INTEGER column.
Because of that default, validateArgTypes must let an argument through whose type the
planner cannot classify — rejecting ANY at plan time makes the function unusable over
any user-defined function's result. Use the shared
isNumericOrUnknownType predicate (types/builtin-types.ts) rather than a bare
isNumeric check: it accepts a numeric type, ANY, or NULL, and defers the decision to
the implementation, which returns null for input it cannot use. (Accepting NULL is also
what makes abs(null) return null instead of raising Invalid argument types.)
The following built-in functions use type inference:
- Numeric functions:
abs(),round(),nullif(),sqrt(),floor(),ceil(),ceiling(),clamp() - Common type resolution:
coalesce(),iif(),greatest(),least(),choose()—greatest()/least()fall back toANYfor a group that is neither all-one-type nor all-numeric, since they return one of their arguments (see "Semantic ordering") - String functions:
length(),upper(),lower(),trim(),ltrim(),rtrim(),substr(),substring(),replace(),reverse(),lpad(),rpad(),instr() - Aggregate functions:
MIN(),MAX() - Arithmetic operators:
+,-,*,/,%with numeric type promotion (INTEGER + INTEGER → INTEGER, INTEGER + REAL → REAL, etc.)
Arithmetic operators follow these type promotion rules:
INTEGER op INTEGER→INTEGERINTEGER op REAL→REALREAL op INTEGER→REALREAL op REAL→REAL
Parameters in Quereus have strong types that are established at prepare time and validated on each execution. This provides type safety while maintaining a user-friendly API for JavaScript developers.
Quereus offers two approaches for specifying parameter types:
- Type Inference from Values - Pass initial parameter values to
prepare()and types are inferred - Explicit Type Hints - Pass a Map of explicit type hints to
prepare()
When you pass parameter values, Quereus automatically infers the logical type based on the JavaScript type:
| JavaScript Type | Logical Type | Example |
|---|---|---|
null |
NULL | null |
number (integer) |
INTEGER | 42, 0, -100 |
number (float) |
REAL | 3.14, 2.5, -0.5 |
bigint |
INTEGER | 9007199254740991n |
boolean |
BOOLEAN | true, false |
string |
TEXT | 'hello', '' |
Uint8Array |
BLOB | new Uint8Array([1, 2, 3]) |
object (plain) |
JSON | { x: 1 }, [1, 2, 3] |
Note: Strings are always inferred as TEXT type. Plain objects and arrays are inferred as JSON type. To use date/time types, either:
- Use conversion functions in your query:
date(:param),time(:param),datetime(:param) - Or pass the value through a conversion function before binding
Parameter types are established during the planning phase and validated on each execution:
- At prepare time: Types are inferred from initial values or set via explicit parameter types
- At execution time: Parameter values are validated against the established types
- No recompilation: Prepared statements are NOT recompiled when parameter values change (only when types would change)
- Type safety: Attempting to execute with incompatible types throws an error
Option 1: Type inference from initial values
// Prepare with initial INTEGER parameters
const stmt = db.prepare('INSERT INTO users (id, age) VALUES (?, ?)', [1, 30]);
// Execute with the initial values
await stmt.run();
// Execute with different INTEGER values (no recompilation)
await stmt.run([2, 25]);
await stmt.run([3, 40]);
// This would throw an error - type mismatch (REAL vs INTEGER)
// await stmt.run([4, 25.5]); // Error: Parameter type mismatch
await stmt.finalize();Option 2: Explicit parameter types
import { INTEGER_TYPE, TEXT_TYPE } from '@quereus/quereus';
// Create explicit parameter types
const parameterTypes = new Map();
parameterTypes.set(1, { typeClass: 'scalar', logicalType: INTEGER_TYPE, nullable: false });
parameterTypes.set(2, { typeClass: 'scalar', logicalType: TEXT_TYPE, nullable: false });
// Prepare with explicit parameter types
const stmt = db.prepare('INSERT INTO users (id, name) VALUES (?, ?)', parameterTypes);
// Execute with matching types
await stmt.run([1, 'Alice']);
await stmt.run([2, 'Bob']);
await stmt.finalize();Named parameters:
// Prepare with named parameters
const stmt = db.prepare(
'INSERT INTO users (id, name, age) VALUES (:id, :name, :age)',
{ id: 1, name: 'Alice', age: 30 }
);
await stmt.run(); // Uses initial values
await stmt.run({ id: 2, name: 'Bob', age: 25 }); // Different values, same types
await stmt.finalize();Date/time conversion:
// String parameter converted to DATE in the query
await db.exec(
'INSERT INTO events (id, event_date) VALUES (?, date(?))',
[1, '2024-01-15']
);
// Or use conversion functions in WHERE clauses
const rows = [];
for await (const row of db.eval(
'SELECT * FROM events WHERE event_date = date(?)',
['2024-01-15']
)) {
rows.push(row);
}Parameter type validation ensures type safety across executions:
- Physical type validation: Validates that JavaScript values are compatible with the physical type of the declared logical type
- Type preservation: Once established, parameter types are preserved across all executions of a prepared statement
- Validation on execution: Each execution validates that parameter values match the established physical types
- NULL compatibility: NULL values are compatible with any nullable parameter type
- Flexible logical types: Different logical types with the same physical type are compatible (e.g.,
numberandbigintboth work for INTEGER physical type) - No implicit conversion: Physical type mismatches are rejected with clear error messages
- Explicit conversion: Use conversion functions like
integer(),real(),text(),date(), etc. in your SQL to convert between types - Array/object scalar guard: A parameter used directly (through
CASTs) as a comparand in a scalar comparison (= <> < <= > >=,IN,BETWEEN) against a non-object scalar operand may not be bound to a JS array or plain object. The OBJECT storage class sorts above every scalar, so such a binding could never match — instead of silently returning no rows it throwsStatusCode.MISMATCHat bind time (e.g.where id = ?with[[1, 2]]). JSON-vs-JSON comparisons (jsoncol = :p), function arguments (json_array_length(?)), projections (select ? as v), and storing into a JSON column are never flagged. Collected bysrc/planner/analysis/scalar-param-usage.tsfrom the logical plan.
Examples of physical type compatibility:
- INTEGER physical type accepts:
number(integer),bigint - REAL physical type accepts:
number(any) - TEXT physical type accepts:
string(any string, including date-like strings) - BOOLEAN physical type accepts:
boolean - BLOB physical type accepts:
Uint8Array - OBJECT physical type accepts: plain objects, arrays (for JSON)
The parameter type system provides significant performance benefits:
- No recompilation: Prepared statements are compiled once and reused, avoiding expensive recompilation
- Early validation: Type errors are caught before execution begins
- Optimized plans: The query planner can optimize based on known parameter types
- Future optimizations: The system is designed to support automatic recompilation for significant optimizations (e.g., when NULL constants enable better plans)
Key files:
src/core/database.ts-prepare()accepts parameter values or explicit types;_buildPlan()passes parameter types to planningsrc/core/statement.ts- Statement class manages parameter types and validationsrc/core/param.ts-getParameterTypes()infers types from parameter valuessrc/types/logical-type.ts-getPhysicalType()determines physical type from JavaScript values;physicalTypeName()provides human-readable namessrc/planner/scopes/param.ts-ParameterScopereceives parameter types directly and uses them during planning
Design:
- Parameter types (not dummy values) are passed directly to the planner
- The planner works with precise logical types from the start
- No intermediate conversion to/from dummy parameter values
- Clean separation between type inference (from JS values) and type usage (in planning)
- Validation checks physical type compatibility, not exact logical type matching
Core Type System:
src/types/logical-type.ts- Core type definitions and interfacessrc/types/registry.ts- Type registry and lookupsrc/types/builtin-types.ts- Built-in type definitions (INTEGER, REAL, TEXT, BLOB, BOOLEAN, DATE, TIME, DATETIME, TIMESPAN)src/types/temporal-types.ts- Temporal type implementationssrc/func/builtins/conversion.ts- Type conversion functions
Type Inference:
src/common/type-inference.ts- Type inference utilities (findCommonType,promoteNumericTypes)src/planner/build-function-call.ts- Planning-time type inference for function calls
Goal: Pre-resolve comparators at index/sort creation time to eliminate runtime type detection.
Current: Comparisons use compareSqlValues() which performs runtime type detection on every call.
Proposed: Pre-create type-specific comparators at index creation time and store them in index metadata.
Performance Target: 2-3x speedup for index operations, joins, and sorts.
Potential future work:
- Indexing JSON properties (functional indexes on
json_extract) - JSON-specific index types for nested queries