Skip to content

feat: SQL type & expression coverage (temporal, numeric, array, predicates, functions)#29

Merged
nevzheng merged 21 commits into
mainfrom
feat/sql-features
Jun 14, 2026
Merged

feat: SQL type & expression coverage (temporal, numeric, array, predicates, functions)#29
nevzheng merged 21 commits into
mainfrom
feat/sql-features

Conversation

@nevzheng

Copy link
Copy Markdown
Owner

Broad expansion of SQL feature coverage, built type-by-type with per-feature spec tests.

Types

  • DATE, TIMESTAMP WITH TIME ZONE, TIME
  • REAL / DOUBLE PRECISION, NUMERIC (order-preserving decimal key encoding)
  • UUID, INTERVAL (Postgres 3-component), one-dimensional ARRAY
  • Order-preserving integer keys (big-endian + sign-flip)

Expressions & predicates

  • IS NULL / IS NOT NULL, LIKE, IN, BETWEEN
  • Full runtime unary minus, || concat, CASE, CAST / :: (full matrix)
  • Scalar functions: ABS, GREATEST/LEAST, CEIL/FLOOR/ROUND, COALESCE, NULLIF,
    CONCAT, SUBSTRING, TRIM, LENGTH/REPEAT/UPPER/LOWER
  • LIMIT / OFFSET (Postgres style)

Hardening & refactors

  • Inline value-size guard (MAX_TEXT_SIZE / MAX_ARRAY_SIZE) with a clear error
  • Split codec.rs and binder.rs into focused submodules
  • Tidied the scalar-function registry

Tests

11 per-feature spec files plus an integration spec combining features in
realistic queries. Full workspace suite + clippy -D warnings clean.

🤖 Generated with Claude Code

nevzheng added 21 commits June 13, 2026 15:10
Add a DATE logical type — days since the Unix epoch, backed by i32 and
rendered as YYYY-MM-DD. chrono::NaiveDate handles calendar parsing,
leap-year validation, and formatting; the on-disk form stays a plain
i32. Supports `DATE 'YYYY-MM-DD'` typed literals, DATE columns, and
date-vs-date comparison (no integer coercion).

Fix the signed-integer KeyCodec to be order-preserving: flip the sign
bit and emit big-endian, so lex byte-sort matches numeric order across
the negative/positive boundary (little-endian sorted negatives after
positives). DATE keys inherit the corrected i32 encoding.

Also fold unary minus on integer literals at bind time, so negative
keys are reachable from SQL (previously `-100` was an unsupported unary
op).

Coverage: temporal/codec/expression unit tests plus E2E specs
(types.slt, key_ordering.slt) exercising literals, validation, NULL
storage, and PK ordering including pre-epoch dates.
Add a tz-aware TIMESTAMP logical type — an absolute instant stored as
i64 microseconds since the Unix epoch, UTC. Following Postgres
timestamptz semantics: an offset in a literal is honored and normalized
to UTC, a literal with no offset is taken as UTC (we have no session
time zone), and output renders as UTC (+00:00). chrono parses the
space- and T-separated forms with optional offset/fractional seconds.

Bare TIMESTAMP (WITHOUT TIME ZONE) is rejected as unsupported — it's a
distinct type we don't model yet. Timestamps ride the i64 codec, so
they inherit the order-preserving key encoding.

Coverage: temporal unit tests (offset normalization, Z/short offset,
no-offset-is-UTC, render roundtrip) plus E2E specs in types.slt and a
key_ordering.slt section for pre-epoch instants.
Add IEEE-754 float types following Postgres sizing: REAL / FLOAT4 are
4-byte (f32), DOUBLE PRECISION / FLOAT8 are 8-byte (f64), bare FLOAT is
double precision, and FLOAT(p) is single for p ≤ 24. Both ride new
fixed-width codecs with an order-preserving key encoding (IEEE total-order
bit-flip + big-endian) so floats sort -inf < … < +inf under lex compare.

Comparisons coerce numerics to f64 via total_cmp (consistent with the
key order); a float against an integer compares numerically. Unsuffixed
decimal/exponent literals (1.5, 1e3) bind as Float64, unary minus folds
negative float literals, and int→float / cross-width float coercion is
allowed on insert (float→int still needs an explicit cast). Output
renders at native width with Postgres spelling (Infinity/-Infinity/NaN).

Coverage: expression coercion tests, codec float key-order test
(signed zero + infinities), and E2E specs — REAL-vs-DOUBLE precision is
observable, plus a DOUBLE key-ordering section spanning ±Infinity.
The parser already accepts function calls; this wires them through the
binder and evaluator. Adds an Expr::Call node with a ScalarFunc
(Abs/Greatest/Least) and row-wise evaluation:

- ABS(x): absolute value over ints/floats; NULL propagates; i*::MIN
  overflows to a type error (Postgres behavior).
- GREATEST/LEAST(a, b, …): largest/smallest non-null argument, returned
  as-is; all-null yields NULL. MAX(a, b, …)/MIN(a, b, …) with two or
  more args are aliases for these.

Single-argument MAX/MIN stay unsupported — they're aggregates that need
GROUP BY. Unknown functions, named/wildcard args, FILTER/OVER/DISTINCT,
and qualified names are rejected.

Coverage: expression unit tests (abs incl. overflow, greatest/least with
null-skipping and numeric coercion) plus an E2E functions.slt spec
covering literals, columns, coercion, and the unsupported aggregate path.
Add an exact decimal type (NUMERIC / DECIMAL) backed by rust_decimal.
Values come from typed `NUMERIC '…'` literals; a bare 1.5 still binds as
a float. In-row storage uses rust_decimal's compact 16-byte form.

The key encoding is the substance: rust_decimal's own bytes aren't
sortable, so storage::codec implements a CockroachDB-style
order-preserving encoding — sign bucket, then exponent, then big-endian
base-100 "centimal" digits, with the payload one's-complemented for
negatives. Equal values (1.5 vs 1.50) encode identically, so a NUMERIC
primary key sorts and dedups correctly.

Comparisons are exact for numeric-vs-numeric and numeric-vs-integer, and
fall back to f64 when a float is involved (matching Postgres promotion).
ABS works on numerics; int→numeric and numeric→float coercions are
allowed on insert. Precision/scale aren't enforced yet (values keep
their own scale; ~28 significant digits).

Coverage: codec tests (value roundtrip, order preservation across sign
and scale, equal-value-equal-key), expression exactness tests, and an
E2E numeric.slt plus a NUMERIC key-ordering section.
codec.rs had grown to ~750 lines holding every per-type impl plus the
decimal encoder and a large test module. Split it into a codec/ module:

- mod.rs   — traits, errors, Value dispatch, the shared `take` helper, tests
- scalar.rs — fixed-width: bool, i16/i32/i64, f32/f64 (incl. the
              order-preserving integer/float key encodings)
- text.rs   — variable-length: Text, Bytes, Json
- decimal.rs — Numeric: 16-byte value form + the order-preserving key encoder

No behavior change; child modules reach the private `take` via `super`.
All tests and specs green.
Extend the scalar-function set:
- CEIL / FLOOR / ROUND — round numbers (int passes through, float and
  numeric round in their own type). CEIL/FLOOR arrive as sqlparser's own
  AST nodes (they support `CEIL(x TO field)`), bound via bind_ceil_floor;
  the TO/scale forms are rejected for now.
- COALESCE(a, b, …) — first non-null argument.
- LENGTH (character count), REPEAT(s, n), UPPER, LOWER — text helpers.

Coverage: expression unit tests and functions.slt assertions for each.
binder.rs had grown to ~914 lines. Split it into a binder/ module by
construct:

- mod.rs   — Binder, BindNode trait, statement dispatch, shared
             three_part_name / is_tz_aware helpers, the Bind pass
- ddl.rs   — CREATE TABLE / CREATE SCHEMA + column & type mapping
- dml.rs   — INSERT + value coercion
- query.rs — SELECT (query body, FROM, WHERE, projection)
- expr.rs  — scalar expressions, functions, typed-string literals

No behavior change. Submodules reach the Binder's private scope methods
and the shared helpers through the parent; the three statement entry
points are pub(super). All tests and specs green.
The Limit operator existed but wasn't bound from SQL, and there was no
OFFSET. Add an Offset operator (skip first n rows) across the logical /
physical / lowering / Volcano layers, and bind `LIMIT k OFFSET n` in the
query binder: OFFSET wraps closest to the input, LIMIT outside it, so it
reads as "skip n, then take k". Counts are constant non-negative
integers (negative LIMIT/OFFSET is a bind error).

ORDER BY and FETCH were previously parsed and silently ignored — now
rejected as unsupported so they don't yield wrong results. The server's
output-column count looks through Limit/Offset to find the projection.

DELETE stays unwired on purpose: a correct DELETE is an MVCC tombstone
(a new version marked deleted), which needs MVCC we don't have yet.

Coverage: Volcano offset/paging unit tests and a limit.slt spec
(LIMIT, OFFSET, combined paging, zero/past-end, negative, ORDER BY reject).
- IS NULL / IS NOT NULL: a new total Expr::IsNull (always true/false,
  never NULL — `NULL IS NULL` is true).
- LIKE / NOT LIKE: a new Expr::Like with a `%`/`_` wildcard matcher
  (two-pointer with backtracking); NULL operands propagate, case-
  sensitive. LIKE ANY / ESCAPE are rejected for now.
- BETWEEN / NOT BETWEEN and IN / NOT IN lower at bind time to existing
  primitives — `x >= low AND x <= high`, and an OR-chain of equalities —
  so SQL 3-valued logic (e.g. `3 IN (1, NULL)` -> NULL) falls out for
  free. Empty `IN ()` is false.

Coverage: expression unit tests and a predicates.slt spec covering each
operator plus the NULL cases.
- Unary minus is now a runtime Expr::Neg evaluated per row, so `-column`
  (and any expression) works, not just constant literals. Replaces the
  bind-time constant-folding shim; i*::MIN overflows to a type error.
- NULLIF(a, b) and CONCAT(a, b, …) as scalar functions (plain calls).
  CONCAT stringifies non-text args and skips NULLs.
- SUBSTRING (1-indexed, optional FROM/FOR) and TRIM (BOTH/LEADING/
  TRAILING, optional chars) — both are dedicated sqlparser AST nodes, so
  they bind through their own arms into Substr / Trim* calls with the SQL
  defaults filled in.

Coverage: expression unit tests for each, plus functions.slt additions
including `-v` over a column.
Until page-span overflow exists, a TEXT/BYTES value larger than a storage
page can't be written and fails opaquely at the page layer. Add a
size check in TableWriter::put against tunable MAX_TEXT_SIZE (8000 B,
comfortably under the 8 KiB page) and MAX_ARRAY_SIZE (1024, for the
future ARRAY type), surfacing a clear "needs page-span support" error
instead. Raise or remove once overflow pages land.
`a || b` maps to a new BinaryOperator::Concat that stringifies both
operands (reusing concat_str) and joins them; NULL propagates. Same
stringification as CONCAT, but the binary operator form.
New Expr::Case: the first branch whose condition is true wins, else the
ELSE result (or NULL). The binder lowers simple `CASE x WHEN v THEN …`
into searched form (`x = v`), so the evaluator only handles boolean
conditions. NULL/false conditions skip, matching SQL semantics.
New Expr::Cast covering every sensible (source, target) pair across our
type set: numerics convert across int/float/numeric widths (float/numeric
-> int rounds, narrowing range-checks); text parses to numbers, bool,
date, timestamp, numeric, json; anything renders to text; date<->timestamp
convert (timestamp->date drops the time, date->timestamp is midnight UTC).
NULL casts to NULL; unconvertible pairs are a clear type error. The
binder reuses the DDL type mapping (bind_data_type, now pub(super)).

Coverage: eval_cast unit tests + a cast.slt spec across the matrix.
- TIME (without time zone): i64 microseconds since midnight; rides the
  i64 codec (order-preserving key). `TIME 'HH:MM:SS'` literals, TIMETZ
  rejected.
- UUID: 16 bytes (uuid crate); raw-bytes key already sorts like Postgres
  orders uuids. `UUID '…'` literals.

Both flow through codec, comparison, the CAST matrix (text<->), concat
stringify, the binder type/literal paths, coercion, and server render.

Coverage: temporal time unit tests, plus types.slt sections for TIME
(literals, ordering, column, cast) and UUID (literal, byte-ordered PK,
cast normalization).
Add E2E rejection tests: out-of-range/garbage TIME and malformed UUID
literals (and casts) error at bind time, matching the parse-side
validation in temporal::parse_time and Uuid::parse_str.
resolve_scalar_func mixed name resolution with ad-hoc, per-function
arity checks (ABS/NULLIF inline, others in grouped arms, REPEAT separate)
— easy to forget when adding a function. Replace it with a single
name -> (ScalarFunc, Arity) table plus one uniform arity validation, so
adding a function is one row. MAX/MIN keep their aggregate special-case.
Also extract eval_nullif so eval_call is uniformly helper-dispatched.
No behavior change.
INTERVAL stores months/days/microseconds separately (a month isn't a
fixed number of days), so '1 mon' and '30 days' store and read back
distinctly. Comparison and the order-preserving key use a normalized
i128 micro count (30-day months, 24-h days), so '1 mon' = '30 days' —
matching Postgres `=`. Value codec keeps the 3 components verbatim (16 B,
new codec/interval.rs); key codec encodes the normalized value.

Parsing/formatting reuse the pg_interval crate (already in the tree) via
our own serde-friendly Interval type. `INTERVAL '…'` literals (postgres /
SQL / ISO-8601 forms, plus `INTERVAL '5' DAY`), CAST text<->interval, and
render all wired.

Coverage: temporal unit tests (normalize-equal-but-distinct, format) and
a types.slt INTERVAL section.
Add ARRAY (single element type, no nesting in v1). LogicalType becomes
recursive (Array(Box<LogicalType>)) and loses Copy; Value::decode and
eval_cast now take &LogicalType. Value::Array(Vec<Value>):
- codec: u32 count + each element (value form); element keys concatenated
  (element-wise ordering, correct for fixed-width elements); recursive
  decode using the column's element type.
- comparison: element-wise (a prefix sorts before a longer array);
  equality coerces element types (Int32 vs Int64).
- ARRAY[...] literals via a runtime Expr::Array; {a,b} rendering; element
  coercion to the column's element type; NULL elements rejected.
- size guard now caps an array's encoded size to a page too.

Nested array types are rejected. Coverage: codec roundtrip + element-wise
key-order unit tests, and a thorough array.slt (round-trip, literals,
comparison, text arrays, nested-rejected, wrong-element-type, null-element).
Per-feature specs exercise features in isolation; add queries.slt that
combines them in realistic queries — LIKE + comparison + AND, projections
stacking || + CASE, string-function + CAST, COALESCE over nullable
columns, LIMIT over a filtered set, and BETWEEN + NOT IN + IS NOT NULL
together. Closes the integration gap where feature interactions weren't
covered.
@nevzheng
nevzheng merged commit 7703056 into main Jun 14, 2026
5 checks passed
@nevzheng
nevzheng deleted the feat/sql-features branch June 14, 2026 00:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant