From f93f87c9d51135aa6d4b8dd451d1571abe3c3055 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 13 Jul 2026 16:13:58 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20filter=20sub-language=20read=20half?= =?UTF-8?q?=20=E2=80=94=20runtime=20+=20studio=20(RFD=200021)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the read half of the filter sub-language: one owned predicate IR, two borrowed frontends, one parameterized SQLite WHERE. Discharges the pagination/ordering debt for derived read surfaces. Runtime (crates/spock-runtime): - src/filter.rs (new): the predicate IR (And/Or/Not/Cmp/In/IsNull/Const) and injection-proof lowering — operators from a closed enum, columns validated + double-quoted, every value a bound `?`. Owns the forced stable total order (user terms + pk tiebreak in the last direction, explicit NULLS placement) and paging (page cap + offset-depth ceiling). Includes the PostgREST frontend (parse_rest) with recursive and/or/not groups and not.-prefix. - graphql.rs: the Hasura bool_exp frontend — per-table _bool_exp / _order_by inputs + _comparison_exp + the order_by enum; where / order_by / offset args on list roots and reverse collections; query_rows rewritten onto the one composer. - http.rs: list_rows takes ordered query params and lowers via parse_rest; reserved-column startup guard (order/limit/offset/select/and/or/not). - v0 ops: eq/neq/gt/gte/lt/lte/in/nin/is_null/ilike + and/or/not. `_like` (case-sensitive) is refused — the SQLite floor is ASCII-case-insensitive and the case-sensitive form needs the banned PRAGMA. Postgres-only tail is a loud bad_request. Ref fields typed as _bool_exp; the key sub-field folds to a direct FK compare. Keyset cursors deferred (offset + 10k ceiling). Studio (crates/spock-runtime/studio) — the Supabase-style console surface: - views/table-view.tsx: Filter + Sort popovers (Supabase table-editor UX) and an offset pager, wired to the REST predicate. Operator menu is symbol+label; closed-set/bool columns get value dropdowns; unary is-null/not-null hide the value. Refusals surface in the grid. Re-fetches only when the effective query changes. - components/ui/popover.tsx (new): shadcn wrapper over @base-ui/react/popover. - lib/query.ts (new): the operator model + the filter/sort/page -> PostgREST query-string builder (the client mirror of filter.rs). Fixture + docs: - examples/filter-lab (new): a domain-less technical fixture (widget + edge) stressing every scalar/closed-set/nullable/ref/composite-key path plus ordering ties, NULL placement, %/_ wildcard escaping and ASCII case folding; FEEDBACK.md records the F1-F7 dogfood findings. - docs: RFD 0021 -> accepted/read-half shipped; RFD 0009 roadmap marks the filter dialect ratified/shipped; graphql.md §7 strikes `_like`. Verified: 201 tests + clippy + fmt green; studio tsc/oxlint/pnpm build clean; read path exercised end-to-end (REST + GraphQL + studio) against filter-lab. Filtered/bulk writes and the v1 policy engine build on this same IR (deferred). --- crates/spock-runtime/src/filter.rs | 730 ++++++++++++++++++ crates/spock-runtime/src/graphql.rs | 459 ++++++++++- crates/spock-runtime/src/http.rs | 59 +- crates/spock-runtime/src/lib.rs | 1 + .../studio/src/components/ui/popover.tsx | 42 + crates/spock-runtime/studio/src/lib/query.ts | 104 +++ .../studio/src/views/table-view.tsx | 512 +++++++++++- crates/spock-runtime/tests/filter.rs | 386 +++++++++ crates/spock-runtime/tests/graphql.rs | 175 +++++ crates/spock-runtime/tests/http.rs | 103 +++ docs/rfd/0009-roadmap.md | 21 +- docs/rfd/0021-filter.md | 23 +- docs/spec/graphql.md | 24 +- examples/filter-lab/FEEDBACK.md | 122 +++ examples/filter-lab/schema.spock | 62 ++ 15 files changed, 2701 insertions(+), 122 deletions(-) create mode 100644 crates/spock-runtime/src/filter.rs create mode 100644 crates/spock-runtime/studio/src/components/ui/popover.tsx create mode 100644 crates/spock-runtime/studio/src/lib/query.ts create mode 100644 crates/spock-runtime/tests/filter.rs create mode 100644 examples/filter-lab/FEEDBACK.md create mode 100644 examples/filter-lab/schema.spock diff --git a/crates/spock-runtime/src/filter.rs b/crates/spock-runtime/src/filter.rs new file mode 100644 index 0000000..6cad0ab --- /dev/null +++ b/crates/spock-runtime/src/filter.rs @@ -0,0 +1,730 @@ +//! The filter sub-language (RFD 0021): one owned predicate IR that both +//! borrowed frontends — Hasura `bool_exp` (GraphQL) and PostgREST operators +//! (REST) — lower into, then to one SQLite `WHERE`. This module owns the IR, +//! the injection-proof lowering, ordering (with a forced stable total order), +//! and paging (page cap + offset-depth ceiling). The GraphQL frontend parses +//! into [`Predicate`] in `graphql.rs`; the REST frontend is [`parse_rest`] +//! here. Nothing outside this module emits SQL for a filter. +//! +//! Safety is structural, not escaping-based (RFD 0021 §8): operators come +//! from a closed enum mapped through a fixed match arm; columns are resolved +//! against the declared field set by the frontend, then double-quoted; and +//! every *value* is a bound `?` parameter — the only length-variable emission +//! is the count of `?` in an `IN` list. + +use rusqlite::types::Value as SqlValue; +use serde_json::Value as Json; +use spock_lang::ir::{Contract, Field, Table, Type}; + +use crate::error::ApiError; +use crate::value::{json_to_sql_scalar, text_to_json_scalar}; + +/// Page *size* (deviation D2): default 50, ceiling 200. +pub const DEFAULT_LIMIT: i64 = 50; +pub const MAX_LIMIT: i64 = 200; +/// Page *depth* (RFD 0021 §7, §14.1): offset is O(n) and holds the single +/// connection lock, so a window ceiling bounds it. Deep paging is the +/// deferred keyset cursor's job, not a deep offset's. +pub const MAX_OFFSET: i64 = 10_000; +/// Tree-global bound-parameter budget (§8.7). Below SQLite's default +/// `SQLITE_LIMIT_VARIABLE_NUMBER` (32766): a predicate whose *total* `?` +/// count — across every leaf, not any one `IN` list — would exceed it is a +/// `bad_request`, never a `prepare()`-time 500. +pub const MAX_FILTER_PARAMS: usize = 30_000; + +/// The single-value comparison operators. `_in`/`_nin` (list) and `_is_null` +/// have their own [`Predicate`] variants; this is the closed set that maps +/// through a fixed match arm to a SQL token — no client operator string ever +/// reaches SQL. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CmpOp { + Eq, + Neq, + Gt, + Gte, + Lt, + Lte, + /// Case-insensitive (ASCII) `LIKE`. `_like`/`like` (case-sensitive) is + /// refused: SQLite `LIKE` is ASCII-case-insensitive and the case-sensitive + /// form needs the banned `PRAGMA case_sensitive_like` (RFD 0021 §4). + Ilike, +} + +impl CmpOp { + fn token(self) -> &'static str { + match self { + CmpOp::Eq => "=", + CmpOp::Neq => "<>", + CmpOp::Gt => ">", + CmpOp::Gte => ">=", + CmpOp::Lt => "<", + CmpOp::Lte => "<=", + CmpOp::Ilike => "LIKE", + } + } +} + +/// The owned predicate tree (RFD 0021 §3), mirroring Hasura `bool_exp` and the +/// PostgREST group grammar — the same shape. The comparison value is a +/// `SqlValue` (v0 `Operand::Lit`); the v1 policy claims binding is a reserved +/// second operand variant — "the same tree with one more binding" — and the +/// cross-table `Exists` node is reserved but never constructed in v0 (§11). +pub enum Predicate { + And(Vec), + Or(Vec), + Not(Box), + Cmp { + col: String, + op: CmpOp, + value: SqlValue, + }, + In { + col: String, + negated: bool, + values: Vec, + }, + IsNull { + col: String, + negated: bool, + }, + /// A constant truth value — the no-filter case (`Const(true)`) and the + /// empty-set canonicalizations of §8.6. + Const(bool), +} + +/// A sort direction. The lowering always emits explicit `NULLS` placement: +/// SQLite's implicit default is inverted from Postgres/Hasura, so it is never +/// inherited (RFD 0021 §7). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Dir { + Asc, + Desc, +} + +impl Dir { + fn sql(self) -> &'static str { + match self { + Dir::Asc => "ASC NULLS LAST", + Dir::Desc => "DESC NULLS FIRST", + } + } +} + +/// Double-quote a SQL identifier, doubling any embedded `"` (§8.3). Columns +/// are validated lowercase identifiers by the time they arrive, so this is +/// belt-and-suspenders — a filter identifier can never degrade into a literal. +fn quote(ident: &str) -> String { + format!("\"{}\"", ident.replace('"', "\"\"")) +} + +fn bool_lit(b: bool) -> &'static str { + if b { + "1" + } else { + "0" + } +} + +/// Lower a predicate to a SQL boolean expression, pushing bound values onto +/// `params` in emission order (§8). The caller inlines the already-validated +/// integer `LIMIT`/`OFFSET`, so `params` carries only filter operands. +pub fn lower_where(pred: &Predicate, params: &mut Vec) -> String { + match pred { + Predicate::Const(b) => bool_lit(*b).to_string(), + Predicate::And(ps) => join(ps, "AND", true, params), + Predicate::Or(ps) => join(ps, "OR", false, params), + Predicate::Not(p) => format!("NOT ({})", lower_where(p, params)), + Predicate::Cmp { col, op, value } => { + params.push(value.clone()); + match op { + // ESCAPE makes a literal % or _ in the pattern inert; the + // client's own %/_ stay wildcards (§4). + CmpOp::Ilike => format!("{} LIKE ? ESCAPE '\\'", quote(col)), + _ => format!("{} {} ?", quote(col), op.token()), + } + } + Predicate::In { + col, + negated, + values, + } => { + if values.is_empty() { + // `_in []` matches nothing; `_nin []` matches everything (§8.6) + return bool_lit(*negated).to_string(); + } + let marks = vec!["?"; values.len()].join(", "); + params.extend(values.iter().cloned()); + let not = if *negated { "NOT " } else { "" }; + format!("{} {not}IN ({marks})", quote(col)) + } + Predicate::IsNull { col, negated } => { + let not = if *negated { "NOT " } else { "" }; + format!("{} IS {not}NULL", quote(col)) + } + } +} + +fn join(ps: &[Predicate], sep: &str, empty: bool, params: &mut Vec) -> String { + if ps.is_empty() { + return bool_lit(empty).to_string(); + } + let parts: Vec = ps.iter().map(|p| lower_where(p, params)).collect(); + format!("({})", parts.join(&format!(" {sep} "))) +} + +/// Lower `ORDER BY` with the forced stable total order (§7): the user's terms, +/// then every key column not already named, appended in the direction of the +/// last user term (ASC when the client gave no order). Single-direction by +/// construction, so rows can never silently skip or duplicate across pages. +pub fn lower_order(terms: &[(String, Dir)], key: &[String]) -> String { + let mut out: Vec = Vec::new(); + for (col, dir) in terms { + out.push(format!("{} {}", quote(col), dir.sql())); + } + let tiebreak = terms.last().map(|(_, d)| *d).unwrap_or(Dir::Asc); + for k in key { + if !terms.iter().any(|(c, _)| c == k) { + out.push(format!("{} {}", quote(k), tiebreak.sql())); + } + } + out.join(", ") +} + +/// Coerce a JSON operand to a SQL value under a field's *value* type (refs +/// chased to the target-key scalar). A malformed uuid/timestamp or wrong JSON +/// shape is `type_mismatch` (§9, §10) — fail-loud, not a silently-empty match. +/// When `check_set_membership`, an off-set value on a closed-set column is +/// `type_mismatch` too (a typo'd enum value fails loudly instead of matching +/// nothing). +pub fn coerce_operand( + contract: &Contract, + table: &Table, + field: &Field, + value: &Json, + check_set_membership: bool, +) -> Result { + let vt = contract.value_type(&field.ty); + let sql = json_to_sql_scalar(vt, value) + .map_err(|expected| ApiError::type_mismatch(&table.name, &field.name, expected))?; + if check_set_membership { + if let (Type::Set { values }, SqlValue::Text(s)) = (vt, &sql) { + if !values.iter().any(|m| m == s) { + return Err(ApiError::type_mismatch( + &table.name, + &field.name, + &format!("one of: {}", values.join(", ")), + )); + } + } + } + Ok(sql) +} + +/// Clamp a requested limit to `[0, MAX_LIMIT]`; a negative value is a caller +/// error. Absence (`None`) is the default page — the frontends supply it. +pub fn clamp_limit(n: i64) -> Result { + if n < 0 { + return Err(ApiError::bad_request("`limit` must be non-negative")); + } + Ok(n.min(MAX_LIMIT)) +} + +/// Validate an offset: non-negative and within the depth ceiling (§7, §14.1). +pub fn check_offset(n: i64) -> Result { + if n < 0 { + return Err(ApiError::bad_request("`offset` must be non-negative")); + } + if n > MAX_OFFSET { + return Err(ApiError::bad_request(format!( + "`offset` exceeds the {MAX_OFFSET}-row depth ceiling; deep paging \ + is the job of a keyset cursor, not a deep offset (RFD 0021 §7)" + ))); + } + Ok(n) +} + +/// Refuse a predicate whose total bound-parameter count would overrun SQLite's +/// variable limit (§8.7) — tree-global, not per-`IN`-list. +pub fn check_params(params: &[SqlValue]) -> Result<(), ApiError> { + if params.len() > MAX_FILTER_PARAMS { + return Err(ApiError::bad_request(format!( + "filter binds {} values, over the {MAX_FILTER_PARAMS} limit", + params.len() + ))); + } + Ok(()) +} + +// ───────────────────────────────────────────────────────────────────────── +// The PostgREST frontend (RFD 0021 §6): `?col=op.val`, `and=(…)`/`or=(…)`, +// `not.` prefixes, `is.{null,true,false,unknown}`, `order`/`limit`/`offset`. +// Parses into the same `Predicate` the GraphQL frontend builds. +// ───────────────────────────────────────────────────────────────────────── + +/// The reserved REST control keys — the exact set the parser treats specially. +/// A table with a column in this set fails startup (`http::router`), so at +/// request time a key here is unambiguously a control key, never a column +/// (§6). +pub const REST_RESERVED_KEYS: &[&str] = &["order", "limit", "offset", "select", "and", "or", "not"]; + +/// A parsed REST list query: the predicate, ordering, and paging. +pub struct RestQuery { + pub predicate: Predicate, + pub order: Vec<(String, Dir)>, + pub limit: i64, + pub offset: i64, +} + +/// Parse an ordered list of query params into a [`RestQuery`]. Repeated keys +/// (multiple column filters, multiple `order`) are honored in order — hence +/// the ordered `&[(String, String)]`, not a map. Every top-level filter is +/// implicitly AND-ed (PostgREST convention). +pub fn parse_rest( + contract: &Contract, + table: &Table, + params: &[(String, String)], +) -> Result { + let mut conds: Vec = Vec::new(); + let mut order: Vec<(String, Dir)> = Vec::new(); + let mut limit = DEFAULT_LIMIT; + let mut offset = 0i64; + + for (key, val) in params { + match key.as_str() { + "limit" => { + let n = val + .parse::() + .map_err(|_| ApiError::bad_request("`limit` must be a non-negative integer"))?; + limit = clamp_limit(n)?; + } + "offset" => { + let n = val.parse::().map_err(|_| { + ApiError::bad_request("`offset` must be a non-negative integer") + })?; + offset = check_offset(n)?; + } + "order" => order.extend(parse_order(table, val)?), + "select" => { + return Err(ApiError::bad_request( + "column projection (`select`) is not supported in v0", + )); + } + "and" => conds.push(Predicate::And(parse_group(contract, table, val)?)), + "or" => conds.push(Predicate::Or(parse_group(contract, table, val)?)), + "not.and" => conds.push(Predicate::Not(Box::new(Predicate::And(parse_group( + contract, table, val, + )?)))), + "not.or" => conds.push(Predicate::Not(Box::new(Predicate::Or(parse_group( + contract, table, val, + )?)))), + // a plain column filter: `?col=op.val` + col => conds.push(parse_column_op(contract, table, col, val)?), + } + } + + let predicate = match conds.len() { + 0 => Predicate::Const(true), + 1 => conds.pop().expect("len checked"), + _ => Predicate::And(conds), + }; + Ok(RestQuery { + predicate, + order, + limit, + offset, + }) +} + +/// `?order=col.asc,col2.desc` — a comma-separated list of `col[.dir]`. A +/// third `.nullsfirst/.nullslast` segment is refused (the nulls-order variants +/// are deferred, §7) rather than silently ignored. +fn parse_order(table: &Table, raw: &str) -> Result, ApiError> { + let mut out = Vec::new(); + for term in raw.split(',').filter(|s| !s.is_empty()) { + let mut parts = term.split('.'); + let col = parts.next().unwrap_or(""); + let dir = match parts.next() { + None | Some("asc") => Dir::Asc, + Some("desc") => Dir::Desc, + Some(other) => { + return Err(ApiError::bad_request(format!( + "unsupported order direction `{other}`; v0 offers `asc` and `desc` \ + (explicit NULLS ordering is deferred, RFD 0021 §7)" + ))); + } + }; + if parts.next().is_some() { + return Err(ApiError::bad_request( + "explicit NULLS ordering (`.nullsfirst`/`.nullslast`) is deferred in v0", + )); + } + resolve_field(table, col)?; + out.push((col.to_string(), dir)); + } + Ok(out) +} + +/// Resolve a column name against the table's declared fields, or `unknown_field`. +fn resolve_field<'a>(table: &'a Table, col: &str) -> Result<&'a Field, ApiError> { + table + .field(col) + .ok_or_else(|| ApiError::unknown_field(&table.name, col)) +} + +/// Split the content of a logical group `(a,b,c)` at top-level commas — +/// respecting nested parentheses and double-quoted spans. +fn split_top_level(s: &str) -> Vec { + let mut out = Vec::new(); + let mut depth = 0i32; + let mut in_quote = false; + let mut cur = String::new(); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '"' => { + in_quote = !in_quote; + cur.push(c); + } + '\\' if in_quote => { + cur.push(c); + if let Some(next) = chars.next() { + cur.push(next); + } + } + '(' if !in_quote => { + depth += 1; + cur.push(c); + } + ')' if !in_quote => { + depth -= 1; + cur.push(c); + } + ',' if !in_quote && depth == 0 => { + out.push(std::mem::take(&mut cur)); + } + _ => cur.push(c), + } + } + if !cur.is_empty() { + out.push(cur); + } + out +} + +/// Parse a logical group body: `(m1,m2,…)`. Each member is either a nested +/// group (`and(…)`, `or(…)`, `not.and(…)`, `not.or(…)`) or a column condition +/// `col.op.val`. +fn parse_group(contract: &Contract, table: &Table, raw: &str) -> Result, ApiError> { + let inner = raw + .strip_prefix('(') + .and_then(|s| s.strip_suffix(')')) + .ok_or_else(|| { + ApiError::bad_request(format!("logical group must be parenthesised: `{raw}`")) + })?; + let mut out = Vec::new(); + for member in split_top_level(inner) { + out.push(parse_group_member(contract, table, &member)?); + } + Ok(out) +} + +/// One member inside a logical group. Distinguishes a nested group from a +/// column condition by the `and(`/`or(`/`not.and(`/`not.or(` prefix. +fn parse_group_member( + contract: &Contract, + table: &Table, + member: &str, +) -> Result { + for (prefix, negated, is_or) in [ + ("not.and(", true, false), + ("not.or(", true, true), + ("and(", false, false), + ("or(", false, true), + ] { + if let Some(rest) = member.strip_prefix(prefix) { + let body = format!("({rest}"); // restore the paren the prefix ate + let members = parse_group(contract, table, &body)?; + let group = if is_or { + Predicate::Or(members) + } else { + Predicate::And(members) + }; + return Ok(if negated { + Predicate::Not(Box::new(group)) + } else { + group + }); + } + } + // a column condition: `col.` (dot-joined, unlike the top-level + // `?col=` where the column is the query key) + let (col, opexpr) = member + .split_once('.') + .ok_or_else(|| ApiError::bad_request(format!("malformed filter `{member}`")))?; + parse_column_op(contract, table, col, opexpr) +} + +/// Parse one column operator expression: `[not.]op.value`, applied to `col`. +fn parse_column_op( + contract: &Contract, + table: &Table, + col: &str, + opexpr: &str, +) -> Result { + // a leading `not.` negates the whole condition (covers `not.is.null`, the + // faithful PostgREST spelling for IS NOT NULL — there is no `is.not_null`) + if let Some(rest) = opexpr.strip_prefix("not.") { + return Ok(Predicate::Not(Box::new(parse_column_op( + contract, table, col, rest, + )?))); + } + let field = resolve_field(table, col)?; + let (op, value) = opexpr + .split_once('.') + .ok_or_else(|| ApiError::bad_request(format!("malformed operator `{opexpr}`")))?; + + let cmp = |op: CmpOp, membership: bool| -> Result { + let json = text_to_json_scalar(contract.value_type(&field.ty), value.to_string()); + Ok(Predicate::Cmp { + col: col.to_string(), + op, + value: coerce_operand(contract, table, field, &json, membership)?, + }) + }; + + match op { + "eq" => cmp(CmpOp::Eq, true), + "neq" => cmp(CmpOp::Neq, true), + "gt" => cmp(CmpOp::Gt, false), + "gte" => cmp(CmpOp::Gte, false), + "lt" => cmp(CmpOp::Lt, false), + "lte" => cmp(CmpOp::Lte, false), + "ilike" => { + // PostgREST aliases `*` → `%` to dodge URL-encoding (§6 deviation) + let pattern = value.replace('*', "%"); + Ok(Predicate::Cmp { + col: col.to_string(), + op: CmpOp::Ilike, + value: SqlValue::Text(pattern), + }) + } + "like" => Err(ApiError::bad_request(format!( + "`like` (case-sensitive) is not supported on the SQLite floor; use `ilike` \ + for case-insensitive matching (`{col}=ilike.{value}`)" + ))), + "in" | "nin" => { + let items = parse_in_list(value)?; + let mut values = Vec::with_capacity(items.len()); + for item in items { + let json = text_to_json_scalar(contract.value_type(&field.ty), item); + values.push(coerce_operand(contract, table, field, &json, true)?); + } + Ok(Predicate::In { + col: col.to_string(), + negated: op == "nin", + values, + }) + } + "is" => match value { + "null" => Ok(Predicate::IsNull { + col: col.to_string(), + negated: false, + }), + "unknown" => Ok(Predicate::IsNull { + col: col.to_string(), + negated: false, + }), + "true" | "false" => { + if !matches!(contract.value_type(&field.ty), Type::Bool) { + return Err(ApiError::type_mismatch(&table.name, col, "a boolean column")); + } + Ok(Predicate::Cmp { + col: col.to_string(), + op: CmpOp::Eq, + value: SqlValue::Integer((value == "true") as i64), + }) + } + other => Err(ApiError::bad_request(format!( + "`is.{other}` is not a valid identity check; use null, not_null (as `not.is.null`), true, or false" + ))), + }, + other => Err(ApiError::bad_request(format!( + "unsupported operator `{other}` on `{}.{col}`", + table.name + ))), + } +} + +/// Parse an `in.(…)` value list: `(a,b,"c,d")`, respecting quotes. +fn parse_in_list(raw: &str) -> Result, ApiError> { + let inner = raw + .strip_prefix('(') + .and_then(|s| s.strip_suffix(')')) + .ok_or_else(|| { + ApiError::bad_request(format!("`in` list must be parenthesised: `{raw}`")) + })?; + Ok(split_top_level(inner) + .into_iter() + .map(|item| unquote(&item)) + .collect()) +} + +/// Strip a surrounding pair of double quotes and unescape `\"`/`\\` inside — +/// PostgREST's quoting for values containing reserved characters. +fn unquote(s: &str) -> String { + let s = s.trim(); + if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') { + let inner = &s[1..s.len() - 1]; + inner.replace("\\\"", "\"").replace("\\\\", "\\") + } else { + s.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lower(pred: &Predicate) -> (String, Vec) { + let mut params = Vec::new(); + let sql = lower_where(pred, &mut params); + (sql, params) + } + + #[test] + fn cmp_lowers_to_bound_param() { + let (sql, params) = lower(&Predicate::Cmp { + col: "age".into(), + op: CmpOp::Gte, + value: SqlValue::Integer(18), + }); + assert_eq!(sql, "\"age\" >= ?"); + assert_eq!(params, vec![SqlValue::Integer(18)]); + } + + #[test] + fn ilike_carries_escape() { + let (sql, _) = lower(&Predicate::Cmp { + col: "name".into(), + op: CmpOp::Ilike, + value: SqlValue::Text("%a%".into()), + }); + assert_eq!(sql, "\"name\" LIKE ? ESCAPE '\\'"); + } + + #[test] + fn empty_in_is_false_empty_nin_is_true() { + let (sql, params) = lower(&Predicate::In { + col: "id".into(), + negated: false, + values: vec![], + }); + assert_eq!(sql, "0"); + assert!(params.is_empty()); + let (sql, _) = lower(&Predicate::In { + col: "id".into(), + negated: true, + values: vec![], + }); + assert_eq!(sql, "1"); + } + + #[test] + fn in_binds_each_value() { + let (sql, params) = lower(&Predicate::In { + col: "k".into(), + negated: false, + values: vec![ + SqlValue::Integer(1), + SqlValue::Integer(2), + SqlValue::Integer(3), + ], + }); + assert_eq!(sql, "\"k\" IN (?, ?, ?)"); + assert_eq!(params.len(), 3); + } + + #[test] + fn is_null_has_no_param() { + let (sql, params) = lower(&Predicate::IsNull { + col: "deleted_at".into(), + negated: true, + }); + assert_eq!(sql, "\"deleted_at\" IS NOT NULL"); + assert!(params.is_empty()); + } + + #[test] + fn and_or_not_nest_and_bind_in_order() { + let (sql, params) = lower(&Predicate::And(vec![ + Predicate::Cmp { + col: "a".into(), + op: CmpOp::Eq, + value: SqlValue::Integer(1), + }, + Predicate::Or(vec![ + Predicate::Cmp { + col: "b".into(), + op: CmpOp::Lt, + value: SqlValue::Integer(2), + }, + Predicate::Not(Box::new(Predicate::IsNull { + col: "c".into(), + negated: false, + })), + ]), + ])); + assert_eq!(sql, "(\"a\" = ? AND (\"b\" < ? OR NOT (\"c\" IS NULL)))"); + assert_eq!(params, vec![SqlValue::Integer(1), SqlValue::Integer(2)]); + } + + #[test] + fn empty_combinators_canonicalize() { + assert_eq!(lower(&Predicate::And(vec![])).0, "1"); + assert_eq!(lower(&Predicate::Or(vec![])).0, "0"); + assert_eq!(lower(&Predicate::Const(true)).0, "1"); + } + + #[test] + fn identifier_quotes_are_doubled() { + // a filter identifier can never break out into a literal + let (sql, _) = lower(&Predicate::IsNull { + col: "we\"ird".into(), + negated: false, + }); + assert_eq!(sql, "\"we\"\"ird\" IS NULL"); + } + + #[test] + fn order_forces_pk_tiebreak_in_last_direction() { + // user sorts desc; the pk tiebreak follows the last term's direction + let sql = lower_order(&[("created".into(), Dir::Desc)], &["id".into()]); + assert_eq!(sql, "\"created\" DESC NULLS FIRST, \"id\" DESC NULLS FIRST"); + // no user order → key ascending + let sql = lower_order(&[], &["id".into()]); + assert_eq!(sql, "\"id\" ASC NULLS LAST"); + // a user term already on the key is not duplicated + let sql = lower_order(&[("id".into(), Dir::Asc)], &["id".into()]); + assert_eq!(sql, "\"id\" ASC NULLS LAST"); + } + + #[test] + fn top_level_split_respects_parens_and_quotes() { + assert_eq!(split_top_level("a.eq.1,b.gt.2"), vec!["a.eq.1", "b.gt.2"]); + assert_eq!( + split_top_level("a.eq.1,or(b.eq.2,c.eq.3)"), + vec!["a.eq.1", "or(b.eq.2,c.eq.3)"] + ); + assert_eq!( + split_top_level("name.eq.\"a,b\",x.gt.1"), + vec!["name.eq.\"a,b\"", "x.gt.1"] + ); + } + + #[test] + fn unquote_strips_and_unescapes() { + assert_eq!(unquote("plain"), "plain"); + assert_eq!(unquote("\"a,b\""), "a,b"); + assert_eq!(unquote("\"a\\\"b\""), "a\"b"); + } +} diff --git a/crates/spock-runtime/src/graphql.rs b/crates/spock-runtime/src/graphql.rs index 559c8cc..b898857 100644 --- a/crates/spock-runtime/src/graphql.rs +++ b/crates/spock-runtime/src/graphql.rs @@ -28,7 +28,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use async_graphql::dynamic::{ - Field, FieldFuture, FieldValue, InputObject, InputValue, Object, ObjectAccessor, + Enum, Field, FieldFuture, FieldValue, InputObject, InputValue, Object, ObjectAccessor, ResolverContext, Scalar, Schema, SchemaError, TypeRef, }; use async_graphql::ErrorExtensions; @@ -42,13 +42,37 @@ use spock_lang::ir::{ use time::format_description::well_known::Rfc3339; use crate::error::ApiError; +use crate::filter::{self, CmpOp, Dir, Predicate}; use crate::func; use crate::value::json_to_sql; use crate::write; use crate::App; -const DEFAULT_LIMIT: i64 = 50; -const MAX_LIMIT: i64 = 200; +/// The scalar comparison-expression input types (Hasura `_comparison_exp`, +/// RFD 0021 §5). One per derived scalar; the text-shaped one also carries +/// `_ilike`. `String` covers text and closed sets (both GraphQL `String`). +const FILTER_SCALARS: &[&str] = &["String", "Int", "Float", "Boolean", "uuid", "timestamp"]; + +/// Global filter type names the derivation reserves so no table can shadow them +/// (mirrors `RESERVED_TABLE_NAMES`, but these are support types not roots): +/// the `order_by` enum and the six `_comparison_exp` inputs. +fn reserved_filter_types() -> Vec { + let mut names = vec!["order_by".to_string()]; + names.extend(FILTER_SCALARS.iter().map(|s| comparison_exp_name(s))); + names +} + +fn comparison_exp_name(scalar: &str) -> String { + format!("{scalar}_comparison_exp") +} + +fn bool_exp_name(table: &str) -> String { + format!("{table}_bool_exp") +} + +fn order_by_name(table: &str) -> String { + format!("{table}_order_by") +} /// Table names the derivation reserves (graphql.md §3): they collide with /// the operation roots or the derived scalars. The language only admits @@ -106,17 +130,29 @@ pub fn schema(app: Arc) -> Result { // cannot shadow the input type derived for `user`. A table's own four // names are pairwise distinct, so any duplicate is cross-table. let mut claimed_types: HashMap = HashMap::new(); // type -> table + // the filter dialect's global support types (RFD 0021): the `order_by` + // enum and the `_comparison_exp` inputs. Claimed first so a table + // (or its support type) that would shadow one fails at load, not per + // request — the same totality the naming laws demand (§13). + for name in reserved_filter_types() { + claimed_types.insert(name, "the filter surface".into()); + } for table in &contract.tables { if RESERVED_TABLE_NAMES.contains(&table.name.as_str()) { return Err(SchemaBuildError::ReservedTableName { table: table.name.clone(), }); } + // `_bool_exp` / `_order_by` join the name space too (spec §3 + // reserved the suffixes; this claims them — closing a request-time + // shadow gap, RFD 0021 §13). for name in [ table.name.clone(), insert_input_name(&table.name), set_input_name(&table.name), pk_columns_name(&table.name), + bool_exp_name(&table.name), + order_by_name(&table.name), ] { if let Some(other) = claimed_types.insert(name.clone(), table.name.clone()) { return Err(SchemaBuildError::DuplicateTypeName { @@ -290,6 +326,18 @@ pub fn schema(app: Arc) -> Result { } } + // Pass 2d — filter input types (RFD 0021): the global `_comparison_exp` + // inputs, and per-table `_bool_exp` / `_order_by`. Built for every + // table, builtin included — `storage_object` is a filterable read-only + // surface (§9). The `order_by` enum is registered in Pass 4. + for scalar in FILTER_SCALARS { + inputs.push(comparison_exp_type(scalar)); + } + for table in &contract.tables { + inputs.push(bool_exp_type(contract, table)); + inputs.push(order_by_type(table)); + } + // Pass 2c — record object types: scalar fields, no relations. for record in &contract.records { let mut obj = Object::new(record.name.clone()); @@ -349,6 +397,8 @@ pub fn schema(app: Arc) -> Result { let mut builder = Schema::build("Query", Some("Mutation"), None) .register(Scalar::new("uuid").description("A UUID in canonical hyphenated form")) .register(Scalar::new("timestamp").description("An RFC 3339 UTC timestamp")) + // the `order_by` direction enum (RFD 0021 §7); v0 ships asc/desc only + .register(Enum::new("order_by").item("asc").item("desc")) .data(app.clone()) // self-references permit unbounded nesting; stay above GraphiQL's // introspection depth while bounding pathological queries (D6) @@ -483,6 +533,62 @@ fn pk_columns_type(contract: &Contract, table: &Table) -> InputObject { input } +/// A scalar comparison-expression input `_comparison_exp` (RFD 0021 +/// §4): the closed operator set for one scalar. Ordered ops take the scalar; +/// `_in`/`_nin` take `[scalar!]`; `_is_null` takes `Boolean`; `_ilike` is +/// text-only. `_like` is deliberately absent — refused, not offered (§4). +fn comparison_exp_type(scalar: &str) -> InputObject { + let mut input = InputObject::new(comparison_exp_name(scalar)); + for op in ["_eq", "_neq", "_gt", "_gte", "_lt", "_lte"] { + input = input.field(InputValue::new(op, TypeRef::named(scalar))); + } + input = input + .field(InputValue::new("_in", TypeRef::named_nn_list(scalar))) + .field(InputValue::new("_nin", TypeRef::named_nn_list(scalar))) + .field(InputValue::new( + "_is_null", + TypeRef::named(TypeRef::BOOLEAN), + )); + if scalar == TypeRef::STRING { + input = input.field(InputValue::new("_ilike", TypeRef::named(TypeRef::STRING))); + } + input +} + +/// The per-table `_bool_exp` (RFD 0021 §5): a field per column — scalar +/// columns typed by their `_comparison_exp`, reference columns by the +/// target's `_bool_exp` (so the key sub-field folds to an FK compare +/// and future traversal is additive) — plus `_and`/`_or` (lists) and `_not`. +fn bool_exp_type(contract: &Contract, table: &Table) -> InputObject { + let name = bool_exp_name(&table.name); + let mut input = InputObject::new(name.clone()); + for f in &table.fields { + let ty = match &f.ty { + Type::Ref { table: target, .. } => TypeRef::named(bool_exp_name(target)), + _ => TypeRef::named(comparison_exp_name(scalar_name(contract, &f.ty))), + }; + input = input.field(InputValue::new(f.name.clone(), ty)); + } + input + .field(InputValue::new( + "_and", + TypeRef::named_nn_list(name.clone()), + )) + .field(InputValue::new("_or", TypeRef::named_nn_list(name.clone()))) + .field(InputValue::new("_not", TypeRef::named(name))) +} + +/// The per-table `_order_by` (RFD 0021 §7): a field per column mapping to +/// the `order_by` enum (`asc`/`desc`). Reference columns order by their FK +/// value directly. +fn order_by_type(table: &Table) -> InputObject { + let mut input = InputObject::new(order_by_name(&table.name)); + for f in &table.fields { + input = input.field(InputValue::new(f.name.clone(), TypeRef::named("order_by"))); + } + input +} + fn gql(e: E) -> async_graphql::Error { async_graphql::Error::new(e.to_string()) } @@ -512,49 +618,47 @@ fn parent_row<'a>(ctx: &ResolverContext<'a>) -> Result<&'a Json, async_graphql:: /// null-coerced unprovided variable) is absence (v0 §5.1) → the default. fn read_limit(ctx: &ResolverContext<'_>) -> Result { match ctx.args.get("limit") { - None => Ok(DEFAULT_LIMIT), - Some(v) if v.is_null() => Ok(DEFAULT_LIMIT), - Some(v) => { - let n = v.i64()?; - if n < 0 { - return Err(gql("`limit` must be non-negative")); - } - Ok(n.min(MAX_LIMIT)) - } + None => Ok(filter::DEFAULT_LIMIT), + Some(v) if v.is_null() => Ok(filter::DEFAULT_LIMIT), + Some(v) => filter::clamp_limit(v.i64()?).map_err(api_error_to_gql), } } -/// Run `SELECT *` over a table (optionally filtered by one column), key -/// order, limited. One lock scope; no await while held (§7.2 discipline). +/// `offset` argument (RFD 0021 §7): default 0, bounded by the depth ceiling. +/// Absent / explicit `null` is 0. +fn read_offset(ctx: &ResolverContext<'_>) -> Result { + match ctx.args.get("offset") { + None => Ok(0), + Some(v) if v.is_null() => Ok(0), + Some(v) => filter::check_offset(v.i64()?).map_err(api_error_to_gql), + } +} + +/// Run `SELECT *` over a table through the one filter composer (RFD 0021 §3): +/// `WHERE ` (bound params), the forced stable total order, and the +/// validated page window. One lock scope; no await while held (§7.2). fn query_rows( app: &App, table: &Table, - filter: Option<(&str, &SqlValue)>, + predicate: &Predicate, + order: &[(String, Dir)], limit: i64, + offset: i64, ) -> Result, async_graphql::Error> { - let order = table - .key - .iter() - .map(|k| format!("\"{k}\" ASC")) - .collect::>() - .join(", "); - let sql = match filter { - Some((column, _)) => format!( - "SELECT * FROM \"{}\" WHERE \"{column}\" = ?1 ORDER BY {order} LIMIT {limit}", - table.name - ), - None => format!( - "SELECT * FROM \"{}\" ORDER BY {order} LIMIT {limit}", - table.name - ), - }; + let mut params: Vec = Vec::new(); + let where_sql = filter::lower_where(predicate, &mut params); + filter::check_params(¶ms).map_err(api_error_to_gql)?; + let order_sql = filter::lower_order(order, &table.key); + let sql = format!( + "SELECT * FROM \"{}\" WHERE {where_sql} ORDER BY {order_sql} LIMIT {limit} OFFSET {offset}", + table.name + ); let db = app.db.lock().map_err(|_| gql("db lock poisoned"))?; let mut stmt = db.prepare(&sql).map_err(gql)?; - let mut rows = match filter { - Some((_, value)) => stmt.query([value]).map_err(gql)?, - None => stmt.query([]).map_err(gql)?, - }; + let mut rows = stmt + .query(rusqlite::params_from_iter(params.iter())) + .map_err(gql)?; let mut out = Vec::new(); while let Some(row) = rows.next().map_err(gql)? { out.push(write::row_to_json(&app.contract, table, row).map_err(gql)?); @@ -562,6 +666,244 @@ fn query_rows( Ok(out) } +/// Parse the `where` argument into a [`Predicate`] (RFD 0021 §5): the value is +/// deserialized whole to JSON and walked, so no accessor gymnastics. Absent / +/// null `where` is the constant-true predicate. +fn parse_where_arg( + contract: &Contract, + table: &Table, + ctx: &ResolverContext<'_>, +) -> Result { + match ctx.args.get("where") { + Some(v) if !v.is_null() => { + let json = v.deserialize::()?; + parse_where_json(contract, table, &json).map_err(api_error_to_gql) + } + _ => Ok(Predicate::Const(true)), + } +} + +/// Walk a `_bool_exp` JSON object into a [`Predicate`]. Multiple keys in one +/// object are implicit `AND`; `_and`/`_or` are lists (the object form is a +/// GraphQL type error by construction); `_not` is one bool_exp. +fn parse_where_json( + contract: &Contract, + table: &Table, + json: &Json, +) -> Result { + let obj = json + .as_object() + .ok_or_else(|| ApiError::bad_request("`where` must be an object"))?; + let mut conds: Vec = Vec::new(); + for (key, val) in obj { + match key.as_str() { + "_and" => conds.push(Predicate::And(parse_bool_exp_list(contract, table, val)?)), + "_or" => conds.push(Predicate::Or(parse_bool_exp_list(contract, table, val)?)), + "_not" => conds.push(Predicate::Not(Box::new(parse_where_json( + contract, table, val, + )?))), + col => { + let field = table + .field(col) + .ok_or_else(|| ApiError::unknown_field(&table.name, col))?; + match &field.ty { + Type::Ref { table: target, .. } => { + conds.push(parse_ref_filter(contract, table, field, target, val)?) + } + _ => conds.push(parse_scalar_cmp(contract, table, field, val)?), + } + } + } + } + Ok(match conds.len() { + 0 => Predicate::Const(true), + 1 => conds.pop().expect("len checked"), + _ => Predicate::And(conds), + }) +} + +fn parse_bool_exp_list( + contract: &Contract, + table: &Table, + val: &Json, +) -> Result, ApiError> { + let arr = val + .as_array() + .ok_or_else(|| ApiError::bad_request("`_and`/`_or` take a list of conditions"))?; + arr.iter() + .map(|e| parse_where_json(contract, table, e)) + .collect() +} + +/// A reference field's filter (RFD 0021 §5): typed as the target's +/// `_bool_exp`, but v0 accepts only the target-*key* sub-field, which +/// folds to a direct comparison on the parent's FK column (no `EXISTS`). Any +/// non-key sub-field is the reserved cross-table traversal → `bad_request`. +fn parse_ref_filter( + contract: &Contract, + table: &Table, + field: &IrField, + target: &str, + val: &Json, +) -> Result { + let target_table = contract + .table(target) + .ok_or_else(|| ApiError::internal("schema/contract drift: ref target"))?; + let key = target_table + .single_key() + .ok_or_else(|| ApiError::internal("schema/contract drift: composite ref target"))?; + let obj = val.as_object().ok_or_else(|| { + ApiError::bad_request(format!("filter on `{}` must be an object", field.name)) + })?; + let mut conds: Vec = Vec::new(); + for (sub, subval) in obj { + if sub != &key.name { + return Err(ApiError::bad_request(format!( + "relationship traversal into `{}.{}` is not supported in v0; filter by the \ + reference key, e.g. {{ {}: {{ {}: {{ _eq: … }} }} }} (RFD 0021 §5)", + table.name, field.name, field.name, key.name + ))); + } + // fold the key sub-comparison onto the parent FK column: the parent + // `field` supplies both the column name and the target-key coercion + // (`value_type` chases the ref to the target key scalar). + conds.push(parse_scalar_cmp(contract, table, field, subval)?); + } + Ok(match conds.len() { + 0 => Predicate::Const(true), + 1 => conds.pop().expect("len checked"), + _ => Predicate::And(conds), + }) +} + +/// A scalar column's comparison expression (`_comparison_exp`): each +/// operator key becomes one leaf; multiple keys are implicit `AND`. +fn parse_scalar_cmp( + contract: &Contract, + table: &Table, + field: &IrField, + val: &Json, +) -> Result { + let obj = val.as_object().ok_or_else(|| { + ApiError::bad_request(format!("filter on `{}` must be an object", field.name)) + })?; + let col = field.name.clone(); + let mut conds: Vec = Vec::new(); + for (op, opval) in obj { + // the NULL law (§8.5): `null` is never a comparison value — route null + // intent through `_is_null`. + if opval.is_null() && op != "_is_null" { + return Err(ApiError::bad_request(format!( + "`{op}: null` on `{}.{}` is not allowed; use `_is_null` for null tests", + table.name, field.name + ))); + } + let cmp = |o: CmpOp, membership: bool| -> Result { + Ok(Predicate::Cmp { + col: col.clone(), + op: o, + value: filter::coerce_operand(contract, table, field, opval, membership)?, + }) + }; + let pred = match op.as_str() { + "_eq" => cmp(CmpOp::Eq, true)?, + "_neq" => cmp(CmpOp::Neq, true)?, + "_gt" => cmp(CmpOp::Gt, false)?, + "_gte" => cmp(CmpOp::Gte, false)?, + "_lt" => cmp(CmpOp::Lt, false)?, + "_lte" => cmp(CmpOp::Lte, false)?, + "_ilike" => cmp(CmpOp::Ilike, false)?, + "_in" | "_nin" => { + let arr = opval.as_array().ok_or_else(|| { + ApiError::bad_request(format!("`{op}` on `{}` takes a list", field.name)) + })?; + let mut values = Vec::with_capacity(arr.len()); + for item in arr { + values.push(filter::coerce_operand(contract, table, field, item, true)?); + } + Predicate::In { + col: col.clone(), + negated: op == "_nin", + values, + } + } + "_is_null" => { + let b = opval.as_bool().ok_or_else(|| { + ApiError::bad_request(format!("`_is_null` on `{}` takes a boolean", field.name)) + })?; + // `_is_null: true` ⇒ IS NULL (negated = false) + Predicate::IsNull { + col: col.clone(), + negated: !b, + } + } + other => { + return Err(ApiError::bad_request(format!( + "unsupported operator `{other}` on `{}.{}`", + table.name, field.name + ))) + } + }; + conds.push(pred); + } + Ok(match conds.len() { + 0 => Predicate::Const(true), + 1 => conds.pop().expect("len checked"), + _ => Predicate::And(conds), + }) +} + +/// Parse the `order_by` argument into an ordered `(column, direction)` list +/// (RFD 0021 §7). Deserialized whole to JSON: a list of single-key objects +/// `[{col: asc|desc}]` (a lone object is tolerated). +fn parse_order_arg( + table: &Table, + ctx: &ResolverContext<'_>, +) -> Result, async_graphql::Error> { + let Some(v) = ctx.args.get("order_by") else { + return Ok(Vec::new()); + }; + if v.is_null() { + return Ok(Vec::new()); + } + let json = v.deserialize::()?; + parse_order_json(table, &json).map_err(api_error_to_gql) +} + +fn parse_order_json(table: &Table, json: &Json) -> Result, ApiError> { + let elements: Vec<&Json> = match json { + Json::Array(a) => a.iter().collect(), + obj @ Json::Object(_) => vec![obj], + _ => { + return Err(ApiError::bad_request( + "`order_by` must be a list of {column: asc|desc}", + )) + } + }; + let mut out = Vec::new(); + for el in elements { + let obj = el + .as_object() + .ok_or_else(|| ApiError::bad_request("each `order_by` entry is an object"))?; + for (col, dirval) in obj { + if table.field(col).is_none() { + return Err(ApiError::unknown_field(&table.name, col)); + } + let dir = match dirval.as_str() { + Some("asc") => Dir::Asc, + Some("desc") => Dir::Desc, + _ => { + return Err(ApiError::bad_request(format!( + "`order_by.{col}` must be `asc` or `desc`" + ))) + } + }; + out.push((col.clone(), dir)); + } + } + Ok(out) +} + /// A scalar field: read the cell from the parent JSON row. fn scalar_field(name: String, ty: TypeRef) -> Field { let field_name = name.clone(); @@ -627,7 +969,9 @@ fn forward_ref_field( } /// A reverse collection on the referenced type: `_by_`, -/// children whose reference column equals the parent's key. +/// children whose reference column equals the parent's key. The client's +/// `where` is AND-ed with that FK bind, so it can never widen the collection +/// (RFD 0021 §5); it carries the same order/page surface as the list root. fn reverse_list_field( name: String, parent_table: String, @@ -635,6 +979,7 @@ fn reverse_list_field( ref_field: String, ) -> Field { let child_type = child_table.clone(); + let child_arg = child_table.clone(); Field::new(name, TypeRef::named_nn_list_nn(child_type), move |ctx| { let parent_table = parent_table.clone(); let child_table = child_table.clone(); @@ -657,18 +1002,32 @@ fn reverse_list_field( .get(&key_field.name) .ok_or_else(|| gql("schema/contract drift: parent key cell"))?; let key = json_to_sql(contract, parent, key_field, cell).map_err(gql)?; + let client = parse_where_arg(contract, child, &ctx)?; + let predicate = Predicate::And(vec![ + Predicate::Cmp { + col: ref_field.clone(), + op: CmpOp::Eq, + value: key, + }, + client, + ]); + let order = parse_order_arg(child, &ctx)?; let limit = read_limit(&ctx)?; - let rows = query_rows(app, child, Some((ref_field.as_str(), &key)), limit)?; + let offset = read_offset(&ctx)?; + let rows = query_rows(app, child, &predicate, &order, limit, offset)?; Ok(Some(FieldValue::list( rows.into_iter().map(FieldValue::owned_any), ))) }) }) + .argument(list_where_arg(&child_arg)) + .argument(list_order_arg(&child_arg)) .argument(InputValue::new("limit", TypeRef::named(TypeRef::INT))) + .argument(InputValue::new("offset", TypeRef::named(TypeRef::INT))) } -/// `Query.(limit): [t!]!` — the bare table name is the list root -/// (Hasura convention). +/// `Query.(where, order_by, limit, offset): [t!]!` — the bare table name is +/// the list root (Hasura convention); the filter args are Tier 2 (RFD 0021). fn root_list_field(table: &Table) -> Field { let table_name = table.name.clone(); Field::new( @@ -678,19 +1037,35 @@ fn root_list_field(table: &Table) -> Field { let table_name = table_name.clone(); FieldFuture::new(async move { let app = app_of(&ctx)?; - let table = app - .contract + let contract = &app.contract; + let table = contract .table(&table_name) .ok_or_else(|| gql("schema/contract drift: table"))?; + let predicate = parse_where_arg(contract, table, &ctx)?; + let order = parse_order_arg(table, &ctx)?; let limit = read_limit(&ctx)?; - let rows = query_rows(app, table, None, limit)?; + let offset = read_offset(&ctx)?; + let rows = query_rows(app, table, &predicate, &order, limit, offset)?; Ok(Some(FieldValue::list( rows.into_iter().map(FieldValue::owned_any), ))) }) }, ) + .argument(list_where_arg(&table.name)) + .argument(list_order_arg(&table.name)) .argument(InputValue::new("limit", TypeRef::named(TypeRef::INT))) + .argument(InputValue::new("offset", TypeRef::named(TypeRef::INT))) +} + +/// The `where: _bool_exp` list argument (RFD 0021 §5). +fn list_where_arg(table: &str) -> InputValue { + InputValue::new("where", TypeRef::named(bool_exp_name(table))) +} + +/// The `order_by: [_order_by!]` list argument (RFD 0021 §7). +fn list_order_arg(table: &str) -> InputValue { + InputValue::new("order_by", TypeRef::named_nn_list(order_by_name(table))) } /// Convert one argument value per the field's *value* type. `Ok(None)` diff --git a/crates/spock-runtime/src/http.rs b/crates/spock-runtime/src/http.rs index b7f4141..692b75b 100644 --- a/crates/spock-runtime/src/http.rs +++ b/crates/spock-runtime/src/http.rs @@ -22,18 +22,18 @@ use serde_json::{json, Map, Value as JsonValue}; use spock_lang::ir::{FnArity, Table, Type}; use crate::error::ApiError; +use crate::filter; use crate::graphql::{self, SchemaBuildError}; use crate::App; use crate::{func, write}; -const DEFAULT_LIMIT: u32 = 50; -const MAX_LIMIT: u32 = 200; /// The dev persona picker caps its projection so a large dev database cannot /// blow up the dropdown (RFD 0015 §6.1). const PERSONA_CAP: u32 = 100; -/// Startup failures: schema-derivation collisions (§8.2 naming laws) or a -/// table claiming a protocol-owned REST segment. Both abort load — never +/// Startup failures: schema-derivation collisions (§8.2 naming laws), a table +/// claiming a protocol-owned REST segment, or a column whose name collides +/// with a reserved PostgREST control key (RFD 0021 §6). All abort load — never /// a request-time surprise. #[derive(Debug, thiserror::Error)] pub enum StartupError { @@ -41,6 +41,11 @@ pub enum StartupError { Schema(#[from] SchemaBuildError), #[error("table `rpc` collides with the protocol-owned /rest/v1/rpc segment")] ReservedRestSegment, + #[error( + "table `{table}` column `{column}` collides with a reserved filter control key \ + (order, limit, offset, select, and, or, not)" + )] + ReservedFilterColumn { table: String, column: String }, } pub fn router(app: Arc) -> Result { @@ -49,6 +54,19 @@ pub fn router(app: Arc) -> Result { if app.contract.table("rpc").is_some() { return Err(StartupError::ReservedRestSegment); } + // A column named like a PostgREST control key (`order`, `and`, …) would be + // unaddressable / ambiguous in a REST filter, so it fails at load, not per + // request — the exact set the parser treats specially (RFD 0021 §6). + for table in &app.contract.tables { + for field in &table.fields { + if filter::REST_RESERVED_KEYS.contains(&field.name.as_str()) { + return Err(StartupError::ReservedFilterColumn { + table: table.name.clone(), + column: field.name.clone(), + }); + } + } + } let schema = graphql::schema(app.clone())?; let gql = Router::new() .route("/graphql/v1", get(graphiql).post(graphql_post)) @@ -414,31 +432,26 @@ fn resolve_table<'a>(app: &'a App, name: &str) -> Result<&'a Table, ApiError> { .ok_or_else(|| ApiError::not_found(format!("no table `{name}` in this contract"))) } -// GET /{table}?limit=N — list rows, key order, capped (§8) +// GET /{table}?col=op.val&order=…&limit=…&offset=… — filtered, ordered, +// paged reads (RFD 0021 §6). The PostgREST operator grammar parses into the +// one predicate IR and runs through the same composer as the GraphQL floor. +// Query params are an ordered `Vec` (not a map): repeated keys — multiple +// column filters, multiple `order` terms — are honored in order. async fn list_rows( State(app): State>, Path(table): Path, - Query(params): Query>, + Query(params): Query>, ) -> Result, ApiError> { let table = resolve_table(&app, &table)?; + let q = filter::parse_rest(&app.contract, table, ¶ms)?; - let limit = match params.get("limit") { - None => DEFAULT_LIMIT, - Some(raw) => raw - .parse::() - .map_err(|_| ApiError::bad_request("`limit` must be a non-negative integer"))? - .min(MAX_LIMIT), - }; - - let order = table - .key - .iter() - .map(|k| format!("\"{k}\" ASC")) - .collect::>() - .join(", "); + let mut sql_params: Vec = Vec::new(); + let where_sql = filter::lower_where(&q.predicate, &mut sql_params); + filter::check_params(&sql_params)?; + let order_sql = filter::lower_order(&q.order, &table.key); let sql = format!( - "SELECT * FROM \"{}\" ORDER BY {order} LIMIT {limit}", - table.name + "SELECT * FROM \"{}\" WHERE {where_sql} ORDER BY {order_sql} LIMIT {} OFFSET {}", + table.name, q.limit, q.offset ); let db = app @@ -449,7 +462,7 @@ async fn list_rows( .prepare(&sql) .map_err(|e| ApiError::internal(format!("sqlite: {e}")))?; let mut rows = stmt - .query([]) + .query(rusqlite::params_from_iter(sql_params.iter())) .map_err(|e| ApiError::internal(format!("sqlite: {e}")))?; let mut out = Vec::new(); diff --git a/crates/spock-runtime/src/lib.rs b/crates/spock-runtime/src/lib.rs index 67a7228..78ae333 100644 --- a/crates/spock-runtime/src/lib.rs +++ b/crates/spock-runtime/src/lib.rs @@ -7,6 +7,7 @@ pub mod engine; pub mod error; +pub mod filter; pub mod func; pub mod graphql; pub mod http; diff --git a/crates/spock-runtime/studio/src/components/ui/popover.tsx b/crates/spock-runtime/studio/src/components/ui/popover.tsx new file mode 100644 index 0000000..bbbf8bc --- /dev/null +++ b/crates/spock-runtime/studio/src/components/ui/popover.tsx @@ -0,0 +1,42 @@ +import { Popover as PopoverPrimitive } from "@base-ui/react/popover" + +import { cn } from "@/lib/utils" + +const Popover = PopoverPrimitive.Root +const PopoverTrigger = PopoverPrimitive.Trigger + +function PopoverContent({ + className, + side = "bottom", + sideOffset = 6, + align = "start", + alignOffset = 0, + ...props +}: PopoverPrimitive.Popup.Props & + Pick< + PopoverPrimitive.Positioner.Props, + "side" | "sideOffset" | "align" | "alignOffset" + >) { + return ( + + + + + + ) +} + +export { Popover, PopoverTrigger, PopoverContent } diff --git a/crates/spock-runtime/studio/src/lib/query.ts b/crates/spock-runtime/studio/src/lib/query.ts new file mode 100644 index 0000000..54c31b6 --- /dev/null +++ b/crates/spock-runtime/studio/src/lib/query.ts @@ -0,0 +1,104 @@ +// The studio table view builds PostgREST-dialect query strings that the runtime +// REST frontend (crates/spock-runtime/src/filter.rs `parse_rest`) lowers into +// the one owned predicate IR — RFD 0021. This is the studio-side mirror of that +// grammar: the operator menu, and the `filter/sort/page` → query-string builder. +// Values are always carried as `URLSearchParams` values (percent-encoded), so a +// literal `%`/`(`/`,` round-trips intact; safety on the floor is structural +// (bound `?` params), never string-escaping here. + +import type { TypeRef } from "@/types" + +// The v0 operator set the floor supports (filter.rs §4). `_like` (case- +// sensitive) is deliberately absent — the SQLite floor refuses it; `ilike` is +// the case-insensitive form. `isnull`/`notnull` take no value. +export type Arity = "value" | "pattern" | "list" | "unary" + +export interface OpDef { + key: string + label: string + // the PostgREST/SQL-ish symbol shown in the operator menu, Supabase-style + symbol: string + arity: Arity +} + +export const FILTER_OPS: OpDef[] = [ + { key: "eq", label: "equals", symbol: "=", arity: "value" }, + { key: "neq", label: "not equal", symbol: "<>", arity: "value" }, + { key: "gt", label: "greater than", symbol: ">", arity: "value" }, + { key: "gte", label: "greater than or equal", symbol: ">=", arity: "value" }, + { key: "lt", label: "less than", symbol: "<", arity: "value" }, + { key: "lte", label: "less than or equal", symbol: "<=", arity: "value" }, + { key: "ilike", label: "like (case-insensitive)", symbol: "~~*", arity: "pattern" }, + { key: "in", label: "in list", symbol: "in", arity: "list" }, + { key: "isnull", label: "is null", symbol: "= ∅", arity: "unary" }, + { key: "notnull", label: "is not null", symbol: "≠ ∅", arity: "unary" }, +] + +const OP_BY_KEY = new Map(FILTER_OPS.map((o) => [o.key, o])) + +export function opDef(key: string): OpDef { + return OP_BY_KEY.get(key) ?? FILTER_OPS[0] +} + +export interface FilterRule { + id: number + column: string + op: string + value: string +} + +export interface SortRule { + column: string + dir: "asc" | "desc" +} + +// The REST value for one rule, e.g. `eq.7`, `ilike.%foo%`, `in.(a,b)`, +// `is.null`, `not.is.null`. Returns null for an incomplete rule (no column, or +// a value-taking op with an empty value) so half-typed filters simply don't +// apply — mirroring Supabase, which never sends an incomplete filter. +export function restValue(rule: FilterRule): string | null { + if (!rule.column) return null + const arity = opDef(rule.op).arity + if (rule.op === "isnull") return "is.null" + if (rule.op === "notnull") return "not.is.null" + const v = rule.value.trim() + if (v === "") return null + if (arity === "list") { + const inner = v.replace(/^\(/, "").replace(/\)$/, "") + return `in.(${inner})` + } + return `${rule.op}.${v}` +} + +// Build the `/rest/v1/{table}?…` query string from the applied filters, sorts, +// and page window. Filters use repeated keys (`?a=eq.1&a=lt.9`) — honored in +// order by the ordered parse on the floor; order/limit/offset are singletons. +export function buildQuery( + filters: FilterRule[], + sorts: SortRule[], + limit: number, + offset: number, +): string { + const p = new URLSearchParams() + for (const f of filters) { + const rest = restValue(f) + if (rest) p.append(f.column, rest) + } + if (sorts.length) { + p.set("order", sorts.map((s) => `${s.column}.${s.dir}`).join(",")) + } + p.set("limit", String(limit)) + if (offset > 0) p.set("offset", String(offset)) + return p.toString() +} + +// A rule is "active" (contributes a predicate) iff it lowers to a REST value. +export function isActiveRule(rule: FilterRule): boolean { + return restValue(rule) !== null +} + +// Closed-set columns get a value dropdown (Supabase renders enums as selects); +// everything else is a free-text value. +export function setValues(type: TypeRef | undefined): string[] | null { + return type?.kind === "set" ? (type.values ?? []) : null +} diff --git a/crates/spock-runtime/studio/src/views/table-view.tsx b/crates/spock-runtime/studio/src/views/table-view.tsx index 70ff0ef..7f9ca19 100644 --- a/crates/spock-runtime/studio/src/views/table-view.tsx +++ b/crates/spock-runtime/studio/src/views/table-view.tsx @@ -2,21 +2,45 @@ import { Component, useRef, useState } from "react" import type { ChangeEvent, ReactNode } from "react" import { DataGrid } from "react-data-grid" import type { Column } from "react-data-grid" -import { Columns3, KeyRound, RefreshCw, Rows3, Search, Upload } from "lucide-react" +import { + ArrowDown, + ArrowDownUp, + ArrowUp, + ChevronLeft, + ChevronRight, + Columns3, + Filter as FilterIcon, + KeyRound, + Plus, + RefreshCw, + Rows3, + Upload, + X, +} from "lucide-react" -import { api } from "@/lib/api" +import { api, isErrorBody } from "@/lib/api" import { AppContext } from "@/lib/app-context" import type { AppState } from "@/lib/app-context" import { useApp } from "@/lib/app-context" import { cellText, defaultStr, typeStr } from "@/lib/contract" +import { buildQuery, FILTER_OPS, isActiveRule, opDef, setValues } from "@/lib/query" +import type { FilterRule, SortRule } from "@/lib/query" import { isFileField, setFileField, uploadFile } from "@/lib/storage" import { cn } from "@/lib/utils" -import type { Table } from "@/types" +import type { Field, Table } from "@/types" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card } from "@/components/ui/card" import { Input } from "@/components/ui/input" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { Doc } from "@/components/doc" import { ErrCodes } from "@/components/err-codes" import { FileThumb } from "@/components/file-thumb" @@ -28,10 +52,34 @@ interface State { mode: Mode rows: Row[] limit: number + offset: number + filters: FilterRule[] + sorts: SortRule[] loading: boolean err: string | null } +// The filter/sort popovers are stateless — every mutation flows back through +// these handlers on TableView, which own the applied predicate and re-fetch. +interface FilterCtl { + add: () => void + remove: (id: number) => void + setColumn: (id: number, column: string) => void + setOp: (id: number, op: string) => void + draftValue: (id: number, value: string) => void // update only, no re-fetch (typing) + commitValue: (id: number, value: string) => void // apply + re-fetch (blur / enter / pick) +} + +interface SortCtl { + add: (column: string) => void + remove: (column: string) => void + toggleDir: (column: string) => void +} + +// Filter rows need stable ids across edits; a module counter is enough (table +// switches remount via `key`, so ids never have to be reconciled). +let filterId = 0 + function clampLimit(v: string): number { const n = parseInt(v, 10) if (Number.isNaN(n)) return 50 @@ -41,9 +89,19 @@ function clampLimit(v: string): number { export class TableView extends Component<{ name: string }, State> { static contextType = AppContext declare context: AppState - state: State = { mode: "data", rows: [], limit: 50, loading: false, err: null } + state: State = { + mode: "data", + rows: [], + limit: 50, + offset: 0, + filters: [], + sorts: [], + loading: false, + err: null, + } private lastActor: string | null = null private lastReload = -1 + private lastQuery: string | null = null private table(): Table | undefined { return this.context.contract.tables.find((t) => t.name === this.props.name) @@ -64,10 +122,14 @@ export class TableView extends Component<{ name: string }, State> { this.lastReload = reloadKey if (this.state.mode === "data") void this.load() } + const s = this.state if ( - prevState.mode !== this.state.mode || - prevState.rows !== this.state.rows || - prevState.limit !== this.state.limit + prevState.mode !== s.mode || + prevState.rows !== s.rows || + prevState.limit !== s.limit || + prevState.offset !== s.offset || + prevState.filters !== s.filters || + prevState.sorts !== s.sorts ) { this.pushStatus() } @@ -77,12 +139,16 @@ export class TableView extends Component<{ name: string }, State> { const table = this.table() if (!table) return this.setState({ loading: true, err: null }) - const res = await api( - `/rest/v1/${encodeURIComponent(table.name)}?limit=${this.state.limit}`, - this.context.actor, - ) + const { filters, sorts, limit, offset } = this.state + const qs = buildQuery(filters, sorts, limit, offset) + this.lastQuery = qs + const res = await api(`/rest/v1/${encodeURIComponent(table.name)}?${qs}`, this.context.actor) if (res.status !== 200) { - this.setState({ loading: false, err: `HTTP ${res.status}`, rows: [] }) + // surface the floor's refusal (unknown_field / type_mismatch / bad_request) + const msg = isErrorBody(res.body) + ? `${res.body.error.code}${res.body.error.message ? " · " + res.body.error.message : ""}` + : `HTTP ${res.status}` + this.setState({ loading: false, err: msg, rows: [] }) return } const body = res.body as { rows?: Row[] } @@ -91,22 +157,98 @@ export class TableView extends Component<{ name: string }, State> { private setMode = (mode: Mode) => this.setState({ mode }) + // Re-fetch only when the effective query actually changes: mutating an + // inactive filter (empty value) or re-blurring an unchanged limit rebuilds + // the same query string, so there is nothing new to fetch. The Refresh + // button and persona switches call `load()` directly to force a re-fetch. + private maybeLoad = () => { + if (!this.table()) return + const { filters, sorts, limit, offset } = this.state + if (buildQuery(filters, sorts, limit, offset) === this.lastQuery) return + void this.load() + } + + // Every predicate/sort/page edit shares one envelope: apply the change, reset + // to the first page, then re-fetch if the query changed. + private reloadWith = (patch: (s: State) => Partial) => + this.setState((s) => ({ ...patch(s), offset: 0 }) as State, () => this.maybeLoad()) + + // --- filter mutations ---------------------------------------------------- + private fAdd = () => { + const f = this.table()?.fields[0] + if (!f) return + this.setState((s) => ({ + filters: [...s.filters, { id: ++filterId, column: f.name, op: "eq", value: "" }], + })) + } + private fRemove = (id: number) => + this.reloadWith((s) => ({ filters: s.filters.filter((r) => r.id !== id) })) + private mapRule = (id: number, fn: (r: FilterRule) => FilterRule, reload: boolean) => { + const next = (s: State) => ({ filters: s.filters.map((r) => (r.id === id ? fn(r) : r)) }) + if (reload) this.reloadWith(next) + else this.setState(next) // typing: update the draft, defer the fetch to commit + } + private fSetColumn = (id: number, column: string) => + this.mapRule(id, (r) => ({ ...r, column }), true) + private fSetOp = (id: number, op: string) => this.mapRule(id, (r) => ({ ...r, op }), true) + private fDraftValue = (id: number, value: string) => + this.mapRule(id, (r) => ({ ...r, value }), false) + private fCommitValue = (id: number, value: string) => + this.mapRule(id, (r) => ({ ...r, value }), true) + + // --- sort mutations ------------------------------------------------------ + // The picker only offers real, not-yet-sorted columns, so `sAdd` needs no guard. + private sAdd = (column: string) => + this.reloadWith((s) => ({ sorts: [...s.sorts, { column, dir: "asc" }] })) + private sRemove = (column: string) => + this.reloadWith((s) => ({ sorts: s.sorts.filter((x) => x.column !== column) })) + private sToggleDir = (column: string) => + this.reloadWith((s) => ({ + sorts: s.sorts.map((x) => + x.column === column ? { ...x, dir: x.dir === "asc" ? "desc" : "asc" } : x, + ), + })) + + // --- paging -------------------------------------------------------------- + private page = (dir: -1 | 1) => { + const offset = Math.max(0, this.state.offset + dir * this.state.limit) + if (offset === this.state.offset) return + this.setState({ offset }, () => this.maybeLoad()) + } + private setLimit = () => this.reloadWith(() => ({})) + private pushStatus() { const table = this.table() if (!table) return - const { mode, rows, limit } = this.state - this.context.setStatus({ - left: - mode === "schema" ? ( + const { mode, rows, limit, offset, filters, sorts } = this.state + if (mode === "schema") { + this.context.setStatus({ + left: ( {table.fields.length} fields · read-only - ) : ( + ), + }) + return + } + const start = rows.length ? offset + 1 : 0 + const end = offset + rows.length + const nFilters = filters.filter(isActiveRule).length + this.context.setStatus({ + left: ( + + rows {start}{end} + {rows.length >= limit ? " (more)" : ""} · read-only + + ), + right: + nFilters || sorts.length ? ( - {rows.length} row{rows.length === 1 ? "" : "s"} - {rows.length >= limit ? " (capped)" : ""} · read-only + {nFilters ? `${nFilters} filter${nFilters === 1 ? "" : "s"}` : ""} + {nFilters && sorts.length ? " · " : ""} + {sorts.length ? `${sorts.length} sort${sorts.length === 1 ? "" : "s"}` : ""} - ), + ) : undefined, }) } @@ -145,9 +287,19 @@ export class TableView extends Component<{ name: string }, State> { render() { const table = this.table() if (!table) return
table not found
- const { mode, rows, limit, loading, err } = this.state + const { mode, rows, limit, offset, filters, sorts, loading, err } = this.state const meCols = table.fields.filter((f) => f.default?.kind === "actor").map((f) => f.name) + const filterCtl: FilterCtl = { + add: this.fAdd, + remove: this.fRemove, + setColumn: this.fSetColumn, + setOp: this.fSetOp, + draftValue: this.fDraftValue, + commitValue: this.fCommitValue, + } + const sortCtl: SortCtl = { add: this.sAdd, remove: this.sRemove, toggleDir: this.sToggleDir } + return (
@@ -183,12 +335,18 @@ export class TableView extends Component<{ name: string }, State> { governance. ) : null} -
-
- Filter — waits on the filter RFD -
+
+ +
-
Reads are actor-blind in v0 — impersonation changes{" "} fn results and = me stamps, not table - reads. File columns (→ storage_object) - upload through the storage gate and attach via the GraphQL floor; other inline{" "} - editing waits on REST writes, and{" "} - filtering waits on the filter RFD. + reads. Filter and Sort{" "} + compile to the PostgREST dialect (?col=eq.…, ?order=…) — the + same predicate IR the GraphQL where uses (RFD 0021); the floor appends the + key to every sort so paging stays stable. File columns + upload through the storage gate; inline editing waits + on REST writes.
{err ? ( -
{err}
+
{err}
) : rows.length === 0 && !loading ? ( -
no rows
+
+ {filters.some(isActiveRule) ? "no rows match the filter" : "no rows"} +
) : ( + }> + Filter + {active ? ( + + {active} + + ) : null} + + + {filters.length === 0 ? ( +
+ No filters applied to this view. +
+ ) : ( +
+ {filters.map((r) => ( + + ))} +
+ )} +
+ +
+
+ + ) +} + +function FilterRow({ table, rule, ctl }: { table: Table; rule: FilterRule; ctl: FilterCtl }) { + const field = table.fields.find((f) => f.name === rule.column) + return ( +
+ + +
+ +
+ +
+ ) +} + +// The value cell adapts to the operator and the column's type: unary ops +// (is null / is not null) take no value; closed-set and bool columns get a +// dropdown of their allowed values; everything else is free text. +function FilterValue({ + field, + rule, + ctl, +}: { + field: Field | undefined + rule: FilterRule + ctl: FilterCtl +}) { + const arity = opDef(rule.op).arity + if (arity === "unary") { + return ( +
+ no value +
+ ) + } + // eq/neq on a closed set or a bool → a dropdown of the allowed values. + const scalarPick = rule.op === "eq" || rule.op === "neq" + const options = scalarPick + ? (setValues(field?.type) ?? (field?.type.kind === "bool" ? ["true", "false"] : null)) + : null + if (options) { + return ( + ctl.commitValue(rule.id, v)} /> + ) + } + const placeholder = arity === "pattern" ? "%text%" : arity === "list" ? "a, b, c" : "value" + return ( + ctl.draftValue(rule.id, e.target.value)} + onBlur={(e) => ctl.commitValue(rule.id, e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") ctl.commitValue(rule.id, (e.target as HTMLInputElement).value) + }} + className="h-6 w-full rounded-md border border-input bg-transparent px-2 font-mono text-[12px] outline-none focus:border-ring" + /> + ) +} + +function ValueSelect({ + value, + options, + onPick, +}: { + value: string + options: string[] + onPick: (v: string) => void +}) { + return ( + + ) +} + +// --- sort popover ---------------------------------------------------------- +// Mirrors Supabase's Sort: applied terms as [column][asc/desc][remove] rows, +// plus a column picker to append one. The floor forces a primary-key tiebreak +// in the last term's direction, so the visible order is always a total order. +function SortButton({ table, sorts, ctl }: { table: Table; sorts: SortRule[]; ctl: SortCtl }) { + const used = new Set(sorts.map((s) => s.column)) + const available = table.fields.filter((f) => !used.has(f.name)) + return ( + + }> + Sort + {sorts.length ? ( + + {sorts.length} + + ) : null} + + + {sorts.length === 0 ? ( +
+ No sorts applied to this view. +
+ ) : ( +
+ {sorts.map((s, i) => ( +
+ {i + 1} + {s.column} + + +
+ ))} +
+ )} + {available.length ? ( +
+
+ Pick a column to sort by +
+
+ {available.map((f) => ( + + ))} +
+
+ ) : null} +
+
+ ) +} + +// --- pager ----------------------------------------------------------------- +// Offset-based prev/next over the page window. There is no total count on the +// floor (examples/filter-lab/FEEDBACK.md F7), so "next" is enabled while the +// page comes back full — the honest signal that another page may exist. +function Pager({ + offset, + count, + limit, + loading, + onPage, +}: { + offset: number + count: number + limit: number + loading: boolean + onPage: (dir: -1 | 1) => void +}) { + const start = count ? offset + 1 : 0 + const end = offset + count + const canPrev = offset > 0 && !loading + const canNext = count >= limit && count > 0 && !loading + return ( +
+ + + {start}–{end} + + +
+ ) +} + function SchemaMode({ table, meCols }: { table: Table; meCols: string[] }) { return (
diff --git a/crates/spock-runtime/tests/filter.rs b/crates/spock-runtime/tests/filter.rs new file mode 100644 index 0000000..122ec1a --- /dev/null +++ b/crates/spock-runtime/tests/filter.rs @@ -0,0 +1,386 @@ +//! The filter sub-language (RFD 0021), exercised end to end against the +//! technical fixture `examples/filter-lab/schema.spock`. Where the graphql/http +//! suites prove the surface exists, this suite probes its *edges* — ordering +//! ties, NULL placement, `%`/`_` escaping, ASCII-only case folding, closed-set +//! membership, the NULL law, and the keyset-skip the RFD deliberately does not +//! paper over. Findings are written up in examples/filter-lab/FEEDBACK.md. + +use std::sync::Arc; + +use serde_json::{json, Value}; +use spock_runtime::{engine, http, App}; + +const SCHEMA: &str = include_str!("../../../examples/filter-lab/schema.spock"); + +async fn start() -> String { + let contract = spock_lang::compile(SCHEMA).expect("filter-lab compiles"); + let conn = engine::open(&contract, None, None).expect("engine opens and seeds"); + let app = Arc::new(App::new(contract, conn)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let addr = listener.local_addr().expect("addr"); + tokio::spawn(async move { http::serve(app, listener).await.expect("serve") }); + format!("http://{addr}") +} + +/// REST GET → (status, body). +async fn get(base: &str, path: &str) -> (u16, Value) { + let resp = reqwest::get(format!("{base}{path}")).await.expect("GET"); + ( + resp.status().as_u16(), + resp.json().await.unwrap_or(Value::Null), + ) +} + +/// The `rows` of a REST list response. +fn rest_rows(body: &Value) -> Vec { + body["rows"].as_array().cloned().unwrap_or_default() +} + +/// The labels of a REST list response, in order. +fn rest_labels(body: &Value) -> Vec { + rest_rows(body) + .iter() + .map(|r| r["label"].as_str().unwrap().to_string()) + .collect() +} + +async fn gql(base: &str, query: &str) -> Value { + reqwest::Client::new() + .post(format!("{base}/graphql/v1")) + .json(&json!({ "query": query })) + .send() + .await + .expect("POST graphql") + .json() + .await + .expect("json") +} + +fn gql_labels(resp: &Value, field: &str) -> Vec { + resp["data"][field] + .as_array() + .unwrap() + .iter() + .map(|r| r["label"].as_str().unwrap().to_string()) + .collect() +} + +/// Every scalar shape filters through both frontends to the same result. +#[tokio::test] +async fn scalar_operators() { + let base = start().await; + + // int: gt / lte / in + let (_, b) = get(&base, "/rest/v1/widget?rank=gt.1").await; + assert_eq!(rest_rows(&b).len(), 2); // d, e + let (_, b) = get(&base, "/rest/v1/widget?rank=lte.1").await; + assert_eq!(rest_rows(&b).len(), 3); // a, b, c + let (_, b) = get(&base, "/rest/v1/widget?rank=in.(2,3)").await; + assert_eq!(rest_rows(&b).len(), 2); // d, e + + // float: gt + let (_, b) = get(&base, "/rest/v1/widget?score=gt.2.0").await; + assert_eq!(rest_rows(&b).len(), 3); // 2.5, 3.5, 10.0 + + // bool: is.true (REST) and _eq: true (GraphQL) agree + let (_, b) = get(&base, "/rest/v1/widget?active=is.true").await; + assert_eq!(rest_rows(&b).len(), 3); // a, c, e + let r = gql(&base, "{ widget(where: {active: {_eq: true}}) { label } }").await; + assert_eq!(gql_labels(&r, "widget").len(), 3); + + // is.null / not.is.null (b has no note) + let (_, b) = get(&base, "/rest/v1/widget?note=is.null").await; + assert_eq!(rest_labels(&b), vec!["Alpha Two"]); + let (_, b) = get(&base, "/rest/v1/widget?note=not.is.null").await; + assert_eq!(rest_rows(&b).len(), 4); + + // timestamp range over canonical (lexically sortable) storage + let (_, b) = get(&base, "/rest/v1/widget?made_at=gte.2026-01-03T00:00:00Z").await; + assert_eq!(rest_rows(&b).len(), 3); // c, d, e +} + +/// Closed sets filter as strings; an off-member operand fails loudly. +#[tokio::test] +async fn closed_set_membership() { + let base = start().await; + + let (_, b) = get(&base, "/rest/v1/widget?kind=eq.alpha").await; + assert_eq!(rest_rows(&b).len(), 2); // a, b + let (_, b) = get(&base, "/rest/v1/widget?kind=in.(beta,gamma)").await; + assert_eq!(rest_rows(&b).len(), 3); // c, d, e + + // an off-set value is a type_mismatch (422), not a silent empty result + let (status, b) = get(&base, "/rest/v1/widget?kind=eq.delta").await; + assert_eq!( + (status, b["error"]["code"].as_str().unwrap()), + (422, "type_mismatch") + ); + let r = gql( + &base, + r#"{ widget(where: {kind: {_eq: "delta"}}) { label } }"#, + ) + .await; + assert_eq!(r["errors"][0]["extensions"]["code"], "type_mismatch"); +} + +/// FINDING territory: `ilike` is ASCII-case-insensitive and treats `%`/`_` as +/// wildcards with no client-reachable escape. These assertions pin the exact +/// behavior recorded in FEEDBACK.md. +#[tokio::test] +async fn ilike_edges() { + let base = start().await; + + // case-insensitive across ASCII: matches "alpha one" and "Alpha Two" + let (_, b) = get(&base, "/rest/v1/widget?label=ilike.*alpha*").await; + assert_eq!(rest_rows(&b).len(), 2); + + // `*` aliases `%`; "50% off" matched by a suffix pattern + let (_, b) = get(&base, "/rest/v1/widget?label=ilike.*off").await; + assert_eq!(rest_labels(&b), vec!["50% off"]); + + // ASCII-only case folding: "café" matches lowercase-exact, but NOT the + // uppercased non-ASCII form — É is not folded to é (FEEDBACK F3). + let r = gql( + &base, + r#"{ widget(where: {label: {_ilike: "café"}}) { label } }"#, + ) + .await; + assert_eq!(gql_labels(&r, "widget"), vec!["café"]); + let r = gql( + &base, + r#"{ widget(where: {label: {_ilike: "CAFÉ"}}) { label } }"#, + ) + .await; + assert_eq!(r["data"]["widget"].as_array().unwrap().len(), 0); + // the ASCII head still folds: CAF% matches café + let r = gql( + &base, + r#"{ widget(where: {label: {_ilike: "CAF%"}}) { label } }"#, + ) + .await; + assert_eq!(gql_labels(&r, "widget"), vec!["café"]); +} + +/// The forced stable total order (RFD 0021 §7): three rank-1 rows tie, yet +/// paging never skips or duplicates because the pk is appended as a tiebreak. +#[tokio::test] +async fn ordering_is_stable_across_ties() { + let base = start().await; + + // the full order by rank asc — the tie (a,b,c) resolves by pk + let (_, all) = get(&base, "/rest/v1/widget?order=rank.asc").await; + let full: Vec = rest_rows(&all) + .iter() + .map(|r| r["id"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(full.len(), 5); + + // paging in windows of 2 reconstructs the exact same order — no gaps, no + // repeats, even though rank alone is ambiguous across the first three rows + let mut paged = Vec::new(); + for offset in [0, 2, 4] { + let (_, page) = get( + &base, + &format!("/rest/v1/widget?order=rank.asc&limit=2&offset={offset}"), + ) + .await; + for r in rest_rows(&page) { + paged.push(r["id"].as_str().unwrap().to_string()); + } + } + assert_eq!(paged, full, "paged order diverged from the full order"); +} + +/// NULLS placement is explicit and Postgres-shaped: last on asc, first on desc +/// (SQLite's implicit default is the opposite and is never inherited). +#[tokio::test] +async fn nulls_sort_explicitly() { + let base = start().await; + + // b has a null note; asc → NULLS LAST + let (_, b) = get(&base, "/rest/v1/widget?order=note.asc").await; + assert_eq!(rest_labels(&b).last().unwrap(), "Alpha Two"); + // desc → NULLS FIRST + let (_, b) = get(&base, "/rest/v1/widget?order=note.desc").await; + assert_eq!(rest_labels(&b).first().unwrap(), "Alpha Two"); +} + +/// FINDING: three-valued logic. `_neq` excludes NULL rows (NULL <> x is NULL, +/// not true), so a row with a null note is absent from `note != "first"` — +/// SQL-faithful, and a genuine footgun worth stating (FEEDBACK F2). +#[tokio::test] +async fn neq_excludes_nulls() { + let base = start().await; + let (_, b) = get(&base, "/rest/v1/widget?note=neq.first").await; + let labels = rest_labels(&b); + assert_eq!(labels.len(), 3); // c, d, e — NOT a ("first") and NOT b (null) + assert!( + !labels.contains(&"Alpha Two".to_string()), + "null-note row leaked in" + ); +} + +/// References filter by their key (folded to the FK column, no EXISTS), and +/// the reverse collection carries its own filter surface. +#[tokio::test] +async fn references_and_reverse() { + let base = start().await; + + // a's id, then widgets whose parent is a + let (_, all) = get(&base, "/rest/v1/widget?label=eq.alpha%20one").await; + let a_id = rest_rows(&all)[0]["id"].as_str().unwrap().to_string(); + let (_, b) = get(&base, &format!("/rest/v1/widget?parent=eq.{a_id}")).await; + assert_eq!(rest_rows(&b).len(), 2); // d, e + + // the same, GraphQL, via the reverse collection with its own order_by + let r = gql( + &base, + r#"{ widget(where: {label: {_eq: "alpha one"}}) { widget_by_parent(order_by: {rank: asc}) { label } } }"#, + ) + .await; + let kids: Vec<&str> = r["data"]["widget"][0]["widget_by_parent"] + .as_array() + .unwrap() + .iter() + .map(|w| w["label"].as_str().unwrap()) + .collect(); + assert_eq!(kids, vec!["50% off", "café"]); // rank 2, then 3 +} + +/// A composite-key table filters and orders through the same composer; the +/// forced total order falls back to the whole compound key. +#[tokio::test] +async fn composite_key_table() { + let base = start().await; + let (_, b) = get(&base, "/rest/v1/edge?weight=gt.4").await; + assert_eq!(rest_rows(&b).len(), 2); // (a,b)=5, (b,c)=8 + // listable and stably ordered even with no single key + let (status, b) = get(&base, "/rest/v1/edge?order=weight.desc").await; + assert_eq!(status, 200); + let weights: Vec = rest_rows(&b) + .iter() + .map(|e| e["weight"].as_i64().unwrap()) + .collect(); + assert_eq!(weights, vec![8, 5, 3]); +} + +/// The refusals: the NULL law, `like`, cross-table traversal, the depth +/// ceiling — each a caller-shaped error, never a 500. +#[tokio::test] +async fn refusals() { + let base = start().await; + + // the NULL law: `_eq: null` is refused (use `_is_null`) + let r = gql(&base, "{ widget(where: {note: {_eq: null}}) { label } }").await; + assert_eq!(r["errors"][0]["extensions"]["code"], "bad_request"); + + // `like` (case-sensitive) is refused with a hint toward `ilike` + let (status, b) = get(&base, "/rest/v1/widget?label=like.alpha").await; + assert_eq!( + (status, b["error"]["code"].as_str().unwrap()), + (400, "bad_request") + ); + + // reserved cross-table traversal (§5): a non-key sub-field on a ref + let r = gql( + &base, + r#"{ widget(where: {parent: {label: {_eq: "alpha one"}}}) { label } }"#, + ) + .await; + assert_eq!(r["errors"][0]["extensions"]["code"], "bad_request"); + + // the offset depth ceiling (§7) + let (status, b) = get(&base, "/rest/v1/widget?offset=99999").await; + assert_eq!( + (status, b["error"]["code"].as_str().unwrap()), + (400, "bad_request") + ); + let r = gql(&base, "{ widget(offset: 99999) { label } }").await; + assert_eq!(r["errors"][0]["extensions"]["code"], "bad_request"); +} + +/// FINDING (the honest one): a bare `_gt` keyset over a non-unique sort column +/// silently skips tie rows — exactly the G16 defect the RFD refuses to hide by +/// NOT productizing keyset in v0 (FEEDBACK F1). This test *pins the footgun* so +/// nobody mistakes it for a supported cursor. +#[tokio::test] +async fn naive_keyset_skips_ties() { + let base = start().await; + + // page 1: order by rank asc, take 2 → the first two rank-1 rows + let (_, p1) = get(&base, "/rest/v1/widget?order=rank.asc&limit=2").await; + let page1 = rest_rows(&p1); + assert_eq!(page1.len(), 2); + let last_rank = page1[1]["rank"].as_i64().unwrap(); // == 1 + + // the "obvious" keyset for page 2: rank > last_rank. It DROPS the third + // rank-1 row entirely — the row is never returned by any such page. + let (_, p2) = get( + &base, + &format!("/rest/v1/widget?rank=gt.{last_rank}&order=rank.asc"), + ) + .await; + let page2_ranks: Vec = rest_rows(&p2) + .iter() + .map(|r| r["rank"].as_i64().unwrap()) + .collect(); + assert!( + !page2_ranks.contains(&1), + "naive keyset unexpectedly kept a tie row: {page2_ranks:?}" + ); + // total rows a client would see across the two "pages" is 4, not 5 — one + // rank-1 row is lost. This is why v0 ships offset + a forced order and + // defers a real keyset cursor. + assert_eq!(page1.len() + rest_rows(&p2).len(), 4); +} + +/// Verifies the factual claims made in FEEDBACK.md so the writeup can't drift +/// from behavior: ref-nullness folds to `IS NULL` (F5), the `\` escape reaches +/// a literal `%` (F4), and a closed-set column tolerates (meaningless) ordered +/// operators (F6). +#[tokio::test] +async fn feedback_claims_hold() { + let base = start().await; + + // F5: nullness of a reference, both terse (REST) and nested (GraphQL) + let (_, b) = get(&base, "/rest/v1/widget?parent=is.null").await; + assert_eq!(rest_rows(&b).len(), 3); // a, b, c have no parent + let r = gql( + &base, + "{ widget(where: {parent: {id: {_is_null: true}}}) { label } }", + ) + .await; + assert_no_gql_errors(&r); + assert_eq!(r["data"]["widget"].as_array().unwrap().len(), 3); + + // F4: the `\` escape reaches a literal `%` — "50% off" matched exactly + let r = gql( + &base, + r#"{ widget(where: {label: {_ilike: "50\\% off"}}) { label } }"#, + ) + .await; + assert_no_gql_errors(&r); + assert_eq!(gql_labels(&r, "widget"), vec!["50% off"]); + // ...while an unescaped `%` is a wildcard: "50%" matches the same row via prefix + let r = gql( + &base, + r#"{ widget(where: {label: {_ilike: "50%"}}) { label } }"#, + ) + .await; + assert_eq!(gql_labels(&r, "widget"), vec!["50% off"]); + + // F6: a closed-set column accepts ordered ops (lexical, meaningless, but + // defined and non-erroring) + let (status, _) = get(&base, "/rest/v1/widget?kind=gt.alpha").await; + assert_eq!(status, 200); +} + +fn assert_no_gql_errors(resp: &Value) { + assert!( + resp["errors"].is_null(), + "unexpected errors: {}", + resp["errors"] + ); +} diff --git a/crates/spock-runtime/tests/graphql.rs b/crates/spock-runtime/tests/graphql.rs index 4b7d305..238a31b 100644 --- a/crates/spock-runtime/tests/graphql.rs +++ b/crates/spock-runtime/tests/graphql.rs @@ -838,3 +838,178 @@ table follow { let resp = gql(&base, r#"{ valid_username(name: "NOPE!") }"#, Value::Null).await; assert_eq!(resp["data"]["valid_username"], false); } + +/// The filter sub-language over GraphQL (RFD 0021): `where` / `order_by` / +/// `offset`, the reserved-Exists refusal, the NULL law, and the depth ceiling. +#[tokio::test] +async fn the_filter_surface() { + let base = start().await; + + // -- introspection: the derived filter types appear --------------------- + let resp = gql(&base, "{ __schema { types { name } } }", Value::Null).await; + assert_no_errors(&resp); + let types: Vec<&str> = resp["data"]["__schema"]["types"] + .as_array() + .unwrap() + .iter() + .map(|t| t["name"].as_str().unwrap()) + .collect(); + for expected in [ + "user_bool_exp", + "user_order_by", + "String_comparison_exp", + "uuid_comparison_exp", + "order_by", + ] { + assert!(types.contains(&expected), "missing filter type {expected}"); + } + + // -- _eq -------------------------------------------------------------- + let resp = gql( + &base, + r#"{ user(where: {username: {_eq: "maya"}}) { username } }"#, + Value::Null, + ) + .await; + assert_no_errors(&resp); + let users = resp["data"]["user"].as_array().unwrap(); + assert_eq!(users.len(), 1); + assert_eq!(users[0]["username"], "maya"); + + // -- _is_null: true (luis has no bio) --------------------------------- + let resp = gql( + &base, + "{ user(where: {bio: {_is_null: true}}) { username } }", + Value::Null, + ) + .await; + assert_no_errors(&resp); + let users = resp["data"]["user"].as_array().unwrap(); + assert_eq!(users.len(), 1); + assert_eq!(users[0]["username"], "luis"); + + // -- order_by desc + the forced pk tiebreak (stable) ------------------ + let resp = gql( + &base, + "{ post(order_by: {caption: desc}) { caption } }", + Value::Null, + ) + .await; + assert_no_errors(&resp); + let caps: Vec<&str> = resp["data"]["post"] + .as_array() + .unwrap() + .iter() + .map(|p| p["caption"].as_str().unwrap()) + .collect(); + assert_eq!(caps, vec!["golden hour", "first light"]); + + // -- _ilike (ASCII case-insensitive), % wildcard ---------------------- + let resp = gql( + &base, + r#"{ post(where: {caption: {_ilike: "%LIGHT%"}}) { caption } }"#, + Value::Null, + ) + .await; + assert_no_errors(&resp); + let caps = resp["data"]["post"].as_array().unwrap(); + assert_eq!(caps.len(), 1); + assert_eq!(caps[0]["caption"], "first light"); + + // -- limit + offset over a stable order ------------------------------- + let resp = gql( + &base, + "{ post(order_by: {caption: asc}, limit: 1, offset: 1) { caption } }", + Value::Null, + ) + .await; + assert_no_errors(&resp); + let caps = resp["data"]["post"].as_array().unwrap(); + assert_eq!(caps.len(), 1); + assert_eq!(caps[0]["caption"], "golden hour"); + + // -- _in -------------------------------------------------------------- + let resp = gql( + &base, + r#"{ user(where: {username: {_in: ["maya", "luis", "ghost"]}}) { username } }"#, + Value::Null, + ) + .await; + assert_no_errors(&resp); + assert_eq!(resp["data"]["user"].as_array().unwrap().len(), 2); + + // -- _and / _or / _not ------------------------------------------------ + let resp = gql( + &base, + r#"{ post(where: {_or: [{caption: {_ilike: "%first%"}}, {caption: {_ilike: "%golden%"}}]}) { caption } }"#, + Value::Null, + ) + .await; + assert_no_errors(&resp); + assert_eq!(resp["data"]["post"].as_array().unwrap().len(), 2); + let resp = gql( + &base, + r#"{ user(where: {_not: {username: {_eq: "maya"}}}) { username } }"#, + Value::Null, + ) + .await; + assert_no_errors(&resp); + let users = resp["data"]["user"].as_array().unwrap(); + assert_eq!(users.len(), 1); + assert_eq!(users[0]["username"], "luis"); + + // -- reference filter by key: fold to the FK column, no EXISTS -------- + let resp = gql( + &base, + r#"{ user(where: {username: {_eq: "maya"}}) { id } }"#, + Value::Null, + ) + .await; + let maya = resp["data"]["user"][0]["id"].as_str().unwrap().to_string(); + let resp = gql( + &base, + &format!(r#"{{ post(where: {{author: {{id: {{_eq: "{maya}"}}}}}}) {{ caption }} }}"#), + Value::Null, + ) + .await; + assert_no_errors(&resp); + assert_eq!(resp["data"]["post"].as_array().unwrap().len(), 2); + + // -- reverse collection carries its own where + order ----------------- + let resp = gql( + &base, + r#"{ user(where: {username: {_eq: "maya"}}) { post_by_author(order_by: {caption: asc}) { caption } } }"#, + Value::Null, + ) + .await; + assert_no_errors(&resp); + let caps: Vec<&str> = resp["data"]["user"][0]["post_by_author"] + .as_array() + .unwrap() + .iter() + .map(|p| p["caption"].as_str().unwrap()) + .collect(); + assert_eq!(caps, vec!["first light", "golden hour"]); + + // -- the NULL law: `_eq: null` is refused, not a silent no-match ------ + let resp = gql( + &base, + "{ user(where: {username: {_eq: null}}) { username } }", + Value::Null, + ) + .await; + assert_eq!(resp["errors"][0]["extensions"]["code"], "bad_request"); + + // -- reserved cross-table traversal is refused (§5) ------------------- + let resp = gql( + &base, + r#"{ post(where: {author: {username: {_eq: "maya"}}}) { caption } }"#, + Value::Null, + ) + .await; + assert_eq!(resp["errors"][0]["extensions"]["code"], "bad_request"); + + // -- the offset depth ceiling (§7) ------------------------------------ + let resp = gql(&base, "{ user(offset: 20000) { username } }", Value::Null).await; + assert_eq!(resp["errors"][0]["extensions"]["code"], "bad_request"); +} diff --git a/crates/spock-runtime/tests/http.rs b/crates/spock-runtime/tests/http.rs index e25b89e..6e8717a 100644 --- a/crates/spock-runtime/tests/http.rs +++ b/crates/spock-runtime/tests/http.rs @@ -536,3 +536,106 @@ async fn a_table_named_rpc_fails_startup() { let app = Arc::new(App::new(contract, conn)); assert!(http::router(app).is_err()); } + +/// The filter sub-language over REST (RFD 0021 §6): the PostgREST operator +/// grammar into the same predicate IR — `col=op.val`, `is.*`, `in.(…)`, +/// `and`/`or` groups, `order`/`offset`, the `like` and depth-ceiling refusals, +/// and the reserved-column startup guard. +#[tokio::test] +async fn the_rest_filter_surface() { + let base = start().await; + + // maya's id, for the reference filter below + let (_, users) = get(&base, "/rest/v1/user").await; + let maya_id = users["rows"] + .as_array() + .unwrap() + .iter() + .find(|r| r["username"] == "maya") + .unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + + let rows = |body: &Value| body["rows"].as_array().unwrap().len(); + + // -- eq --------------------------------------------------------------- + let (status, body) = get(&base, "/rest/v1/user?username=eq.maya").await; + assert_eq!(status, 200); + assert_eq!(rows(&body), 1); + assert_eq!(body["rows"][0]["username"], "maya"); + + // -- is.null / not.is.null (luis has no bio) -------------------------- + let (_, body) = get(&base, "/rest/v1/user?bio=is.null").await; + assert_eq!(rows(&body), 1); + assert_eq!(body["rows"][0]["username"], "luis"); + let (_, body) = get(&base, "/rest/v1/user?bio=not.is.null").await; + assert_eq!(rows(&body), 1); + assert_eq!(body["rows"][0]["username"], "maya"); + + // -- in.(…) ----------------------------------------------------------- + let (_, body) = get(&base, "/rest/v1/user?username=in.(maya,luis,ghost)").await; + assert_eq!(rows(&body), 2); + + // -- ilike with the * → % alias -------------------------------------- + let (_, body) = get(&base, "/rest/v1/user?username=ilike.*MAY*").await; + assert_eq!(rows(&body), 1); + assert_eq!(body["rows"][0]["username"], "maya"); + + // -- implicit AND of two column filters ------------------------------- + let (_, body) = get(&base, "/rest/v1/user?username=eq.maya&bio=eq.photographer").await; + assert_eq!(rows(&body), 1); + + // -- an or() group ---------------------------------------------------- + let (_, body) = get( + &base, + "/rest/v1/user?or=(username.eq.maya,username.eq.luis)", + ) + .await; + assert_eq!(rows(&body), 2); + + // -- order desc + forced pk tiebreak ---------------------------------- + let (_, body) = get(&base, "/rest/v1/user?order=username.desc").await; + let names: Vec<&str> = body["rows"] + .as_array() + .unwrap() + .iter() + .map(|r| r["username"].as_str().unwrap()) + .collect(); + assert_eq!(names, vec!["maya", "luis"]); + + // -- reference filter: the FK scalar column, folded to `=` ------------ + let (_, body) = get(&base, &format!("/rest/v1/post?author=eq.{maya_id}")).await; + assert_eq!(rows(&body), 1); + + // -- `like` (case-sensitive) is refused with a naming hint ------------ + let (status, body) = get(&base, "/rest/v1/user?username=like.maya").await; + assert_eq!((status, error_code(&body)), (400, "bad_request")); + + // -- the offset depth ceiling ----------------------------------------- + let (status, body) = get(&base, "/rest/v1/user?offset=20000").await; + assert_eq!((status, error_code(&body)), (400, "bad_request")); + + // -- an unknown column is unknown_field ------------------------------- + let (status, body) = get(&base, "/rest/v1/user?nope=eq.1").await; + assert_eq!((status, error_code(&body)), (422, "unknown_field")); + + // -- a bad order direction is bad_request ----------------------------- + let (status, body) = get(&base, "/rest/v1/user?order=username.sideways").await; + assert_eq!((status, error_code(&body)), (400, "bad_request")); +} + +/// A column named like a reserved PostgREST control key fails at load, not per +/// request (RFD 0021 §6) — the same totality as the other naming laws. +#[tokio::test] +async fn reserved_filter_column_fails_startup() { + let contract = spock_lang::compile("table thing { key id: uuid = auto\n order: int }") + .expect("compiles: `order` is a legal spock column name"); + let conn = engine::open(&contract, None, None).expect("engine opens"); + let app = Arc::new(App::new(contract, conn)); + let err = http::router(app).unwrap_err(); + assert!( + matches!(err, http::StartupError::ReservedFilterColumn { .. }), + "expected ReservedFilterColumn, got {err:?}" + ); +} diff --git a/docs/rfd/0009-roadmap.md b/docs/rfd/0009-roadmap.md index 5c76162..f47c650 100644 --- a/docs/rfd/0009-roadmap.md +++ b/docs/rfd/0009-roadmap.md @@ -114,8 +114,9 @@ until doctrine asks (graphql.md §7). ## 3. The order Tier-1 rename → codegen proof → `fn` → **fn v2 (RFD 0012)** → -**value constraints (RFD 0013)** → filter RFD → Tier 2 + REST writes → -auth — with track 9 filling gaps and track 8 trailing usage throughout. +**value constraints (RFD 0013)** → **filter, read half (RFD 0021)** → +REST/GraphQL bulk writes → auth — with track 9 filling gaps and track 8 +trailing usage throughout. (Revised July 2026: fn v2 — declared refusals, multi-statement bodies, read/write polarity — jumped ahead of the filter RFD on the dogfood @@ -130,6 +131,13 @@ same third rule. It resolves the `format` question §4 deferred, and it un-collapses the fn-guard refusals the borrowed floor cannot keep (v0-FEEDBACK G1/G13). The filter RFD is next.) +(Shipped July 2026: the filter sub-language's read half — RFD 0021. One +owned predicate IR, two borrowed frontends, forced stable total order, +page + offset-depth caps, the pagination debt above discharged for derived +surfaces (read fns stay author-owned — the `view` boundary). Dogfooded by +the technical fixture `examples/filter-lab/`. Filtered/bulk *writes* and v1 +`policy` build on the same IR.) + Four rules generate this ordering; if the ordering is ever revisited, argue with the rules, not the sequence: @@ -160,9 +168,12 @@ with the rules, not the sequence: hand-written `spock` npm client after REST writes, per-app generated types); generate types, never the client; the GraphQL path stays borrowed. -- **Filter dialect.** Recommendation: one predicate IR, two mirrored - frontends (Hasura `bool_exp`, PostgREST operators) — doctrine applied, - but it is contract surface, so it is recorded here until ratified. +- ~~**Filter dialect.**~~ **Ratified and shipped — RFD 0021.** One owned + predicate IR (`spock-runtime`), two mirrored frontends (Hasura `bool_exp`, + PostgREST operators); the read half (`where`/`order_by`/`offset`, forced + stable total order, page + depth caps) is live and dogfooded by + `examples/filter-lab/`. Filtered/bulk *writes* build on the same IR and land + with the REST-writes milestone; the IR is shaped as the v1 `policy` dry-run. - ~~**`format` — column formats as a language feature**~~ **Resolved — RFD 0013** (July 2026). The research ran (judged design panel: curated format vocabulary vs raw-SQL check vs named domain — all three rejected) diff --git a/docs/rfd/0021-filter.md b/docs/rfd/0021-filter.md index 62a57a6..3c43217 100644 --- a/docs/rfd/0021-filter.md +++ b/docs/rfd/0021-filter.md @@ -1,13 +1,20 @@ # RFD 0021 — the filter sub-language: one predicate IR, two borrowed frontends -Status: **proposed** — recommending acceptance. This ratifies the filter-dialect -recommendation RFD 0009 §4 recorded "until ratified" and unblocks the tier the -whole surface has been waiting on: `docs/spec/graphql.md` §7 Tier 2 and filtered -REST are "blocked on one deliberate decision: the filter language (the same -decision REST writes wait on — one design should serve both)." This RFD is that -one decision. It also discharges the pagination/cursor debt fn v2 deferred here -(RFD 0012 §3), and it deliberately shapes its predicate IR as the structural -dry-run for v1 `policy`/RLS. §14 flags the decisions that remain genuinely open. +Status: **ACCEPTED — read half implemented in v0.** The predicate IR +(`crates/spock-runtime/src/filter.rs`), both frontends (Hasura `bool_exp` on +`/graphql/v1`, PostgREST operators on `/rest/v1`), ordering with the forced +stable total order, and paging all shipped and are exercised end to end by the +technical fixture `examples/filter-lab/` (see its FEEDBACK.md) plus the +graphql/http suites. `docs/spec/graphql.md` §7 is reconciled (`_like` struck). +The open decisions of §14 were resolved as recommended (offset window ceiling +10 000; `STRICT` tables deferred; shallow key-traversal ref filter shipped; +unknown-operator folded into `bad_request`). Still deferred, on this exact IR: +filtered/bulk **writes** (the REST-writes milestone) and v1 `policy` (the +reserved `Operand::Actor` / `Exists` seams, §11). This ratifies the +filter-dialect recommendation RFD 0009 §4 recorded "until ratified"; it +discharges the pagination/cursor debt fn v2 deferred here (RFD 0012 §3), and it +deliberately shapes its predicate IR as the structural dry-run for v1 +`policy`/RLS. The filter sub-language is the fifth thing the roadmap has called "next" and the first to actually land the query layer. It has one job, seen from three angles: diff --git a/docs/spec/graphql.md b/docs/spec/graphql.md index 6ef4a2e..c27c699 100644 --- a/docs/spec/graphql.md +++ b/docs/spec/graphql.md @@ -215,15 +215,21 @@ an off-set value is the `invalid` error above. `_by_pk` / `_one` operations, relationships both directions, `limit`, derived errors in extensions. This is exactly the subset of Hasura that requires no filter language. -- **Tier 2 — filtered and bulk**: `where: _bool_exp` (Hasura's grammar: - per-field comparison expressions `_eq _neq _gt _gte _lt _lte _in _nin - _is_null`, text `_like _ilike`, combinators `_and _or _not`), - `order_by: [_order_by!]` (`asc | desc`), `offset`, and the bulk - mutations `insert_(objects:)`, `update_(where:, _set:)`, - `delete_(where:)` returning `_mutation_response - { affected_rows: Int!, returning: [t!]! }`. Blocked on one deliberate - decision: the filter language (the same decision REST writes wait on — - one design should serve both). +- **Tier 2 — filtered and bulk**. The *read* half **shipped** (RFD 0021): + `where: _bool_exp` (per-field comparison expressions `_eq _neq _gt _gte + _lt _lte _in _nin _is_null`, text `_ilike`, combinators `_and _or _not`), + `order_by: [_order_by!]` (`asc | desc`), and `offset` land on every list + root and reverse collection. `_like` (case-sensitive) is **refused, not + offered** — the SQLite floor's `LIKE` is ASCII-case-insensitive and the + case-sensitive form needs the banned `PRAGMA case_sensitive_like`, so + advertising `_like` would lie about the floor (RFD 0021 §4). Reference + fields carry the target's `_bool_exp`, of which v0 accepts the + key sub-field (folded to an FK comparison); cross-table traversal is a + reserved node, refused for now (§5). The *write* half — the bulk mutations + `insert_(objects:)`, `update_(where:, _set:)`, `delete_(where:)` + returning `_mutation_response { affected_rows: Int!, returning: [t!]! }` + — is still pending; it builds on the same predicate IR and is gated to the + REST-writes milestone (keeping Hasura's non-null-`where` anti-footgun wall). - **Tier 3 — conveniences**: `on_conflict` upsert (blocked on the language-level upsert semantics, v1-FEEDBACK L2 — the dialect must not back into semantics the language has not decided), `_inc` and update diff --git a/examples/filter-lab/FEEDBACK.md b/examples/filter-lab/FEEDBACK.md new file mode 100644 index 0000000..25d8a56 --- /dev/null +++ b/examples/filter-lab/FEEDBACK.md @@ -0,0 +1,122 @@ +# filter-lab — feedback + +Where `examples/instagram/*-FEEDBACK.md` reviews a *product* against a PRD, +this file reviews the *filter sub-language* (RFD 0021) against a schema built +only to stress it. Every finding below was produced by exercising +`schema.spock` end to end through both frontends +(`crates/spock-runtime/tests/filter.rs`) — the Hasura `bool_exp` surface on +`/graphql/v1` and the PostgREST operator surface on `/rest/v1`. + +The fixture is deliberately meaningless: one `widget` table with a column of +every filterable shape (text, int, float, bool, a closed set, a nullable +column, a timestamp, a self-reference) plus a composite-key `edge` table, and +five seed rows chosen to straddle the edges — three rank-1 rows (an ordering +tie), one null note, labels carrying a literal `%`, a literal `_`, and a +non-ASCII accent. + +Cross-references like §7 point into `docs/rfd/0021-filter.md`; F1-style tags +are this file's own. + +## Where the language holds + +The load-bearing claims of RFD 0021 survive contact with the fixture: + +- **One IR, two skins, one result.** Every probe that has both spellings — + `?active=is.true` vs `where: {active: {_eq: true}}`, `?rank=gt.1` vs + `where: {rank: {_gt: 1}}` — returns the identical row set. The frontends are + genuinely projections of one predicate tree. +- **The forced total order is real (F-none).** Three rows share `rank = 1`. + Paged `order=rank.asc&limit=2` in three windows, the concatenation is + byte-identical to the un-paged order — no row skipped, none doubled. The + appended primary-key tiebreak (§7) is doing exactly its job. +- **Refusals are caller-shaped, never 500.** The NULL law, `like`, cross-table + traversal, an off-set closed value, the offset ceiling, an unknown column, + a bad order direction — every one is a `bad_request` / `type_mismatch` / + `unknown_field` with the §8.1 envelope. Nothing reaches SQLite to fail at + `prepare()`. +- **Closed-set membership fails loud.** `kind=eq.delta` is a `type_mismatch` + (422), not a silently-empty result — a typo in an enum filter is caught, not + swallowed. +- **`storage_object`-style read-only builtins and composite keys** filter and + order through the same composer with no special-casing. + +## Findings + +**F1 · A naive `_gt` keyset silently skips tie rows — and the language is +right not to hide it.** Ordering `widget` by the non-unique `rank`, the +"obvious" page-2 cursor `?rank=gt.` drops *every other* row sharing +that rank: page 1 returns two rank-1 rows, page 2 (`rank=gt.1`) returns only +ranks 2–3, and the third rank-1 row is returned by *no* page — 4 of 5 rows +seen, one lost. This is `examples/instagram/v0-FEEDBACK.md` G16 reproduced in a +petri dish. The forced pk tiebreak fixes the `ORDER BY`, but it cannot fix a +client's `WHERE` boundary, and neither borrowed dialect has a tuple-comparison +surface to express the sound compound cursor. The fixture is the evidence that +§7's call — ship offset + a forced order, *defer* a productized keyset rather +than advertise a broken one — is the honest one. → §7 confirmed; the test +`naive_keyset_skips_ties` pins the footgun so no client mistakes it for a +cursor. Productized keyset stays deferred. + +**F2 · `_neq` (and the NOT-family) drops NULL rows.** `note=neq.first` returns +three rows — not four. The row with a *null* note is excluded, because +`NULL <> 'first'` is `NULL`, not `true` (three-valued logic). This is +SQL-exact and matches Postgres/Hasura verbatim, so "correcting" it would break +the borrow — but it is a real footgun: a user reads `note != "first"` as +"everything except first" and does not expect the unset rows to vanish. +→ §8.5: the NULL law forbids `_eq: null` and routes null intent through +`_is_null`, which is the right lever; the residual 3VL exclusion is inherent +to the borrowed semantics and is documented, not patched. + +**F3 · `ilike` case folding is ASCII-only.** `_ilike: "CAF%"` matches `café` +(the ASCII head folds), but `_ilike: "CAFÉ"` matches *nothing* — `É` is not +folded to `é`. This is SQLite's bundled `LIKE` (§4), and it is a genuine +surprise for any non-English data: a case-insensitive search that quietly +isn't, past the ASCII range. → §4: `_ilike` is honestly the ASCII-case-fold +`LIKE`; a Unicode-aware match waits for a real backend (or an ICU build). Worth +a one-line caveat wherever `_ilike` is documented for clients. + +**F4 · `%`/`_` are wildcards with a non-obvious escape, and REST `*` cannot be +matched at all.** The label `50% off` contains a literal `%`; `beta_three` +contains a literal `_`. Both characters are `LIKE` wildcards, so filtering for +them literally requires the `\` escape the lowering already installs +(`ESCAPE '\'`, §4) — reachable but undocumented and easy to miss. Worse on +REST: because the skin aliases `*` → `%` (§6), a literal `*` in the data is +*unmatchable* through `ilike` there. → §6 records the `*` alias as a deviation; +this fixture shows its user-visible edge. Candidate for the clients doc: +"to match a literal `%`/`_`, escape it `\%`/`\_`; `*` is not literally +matchable over REST `ilike`." + +**F5 · The GraphQL reference-filter spelling is verbose next to REST's.** To +filter posts by author on REST it is the terse `?parent=eq.`; on GraphQL it +is the nested `where: {parent: {id: {_eq: }}}` — the key must be named +because the ref field is typed as the target's `bool_exp` for forward +compatibility (§5). The nesting is correct and future-proof (the reserved +`Exists` node attaches to the same field type), but it is one level deeper than +a naive author writes, and the two frontends read asymmetrically for the single +most common relationship filter. → §5, accepted: the verbosity buys a `where` +schema that never breaks when cross-table traversal lands. Filtering a ref for +*null* works through the same door — `{parent: {id: {_is_null: true}}}` folds +to `"parent" IS NULL` — which is handy but equally indirect. + +**F6 · A closed-set column accepts ordered operators that mean nothing.** +Because a set field is a GraphQL `String`, its `bool_exp` is +`String_comparison_exp`, so `kind=gt.alpha` or `kind=ilike.*a*` parse and run +as lexical string operations. Harmless (they lower to valid SQL) but +semantically meaningless — only `_eq`/`_neq`/`_in`/`_nin`/`_is_null` are +sensible on a closed set. → minor; §9 already notes only the equality family is +"meaningful". Restricting the operator set per column type is a possible later +tightening, but it costs a per-scalar comparison-exp split for a footgun that +merely returns odd-but-defined results. + +**F7 · No total count for a paged client.** Offset paging is available and +stable, but there is no way to ask "how many rows match this filter?" — so a +client cannot render "page 3 of 12" or a result count without over-fetching. +→ §12, expected: `count` (Hasura `_aggregate`, PostgREST `count=exact`) is +explicitly deferred. This fixture confirms the gap is felt the moment you page. + +## The one-line verdict + +The filter layer does what §0 promised — the author writes a predicate, and the +protocol owns the page, the order, and the escaping — and every place it is +*sharp* (F1–F4) is a place the RFD already chose to be honest rather than +clever. Nothing here is a bug; the findings are the price list, and it is the +one the design already quoted. diff --git a/examples/filter-lab/schema.spock b/examples/filter-lab/schema.spock new file mode 100644 index 0000000..34b4739 --- /dev/null +++ b/examples/filter-lab/schema.spock @@ -0,0 +1,62 @@ +//! filter-lab — a technical fixture for the filter sub-language (RFD 0021). +//! +//! Unlike `examples/instagram` (a plausible product), this schema models no +//! domain. It exists only to stress the query layer: one filterable column of +//! every scalar shape, a closed set, nullable columns for the NULL law, a +//! self-reference for FK and reverse-collection filtering, a composite key, +//! and seed rows chosen to straddle the sharp edges — ordering ties, NULL +//! placement, `%`/`_` wildcard escaping, and ASCII-only case folding. +//! +//! The findings this fixture surfaced are recorded in FEEDBACK.md, in the +//! dogfooding tradition of the instagram example. It is exercised end to end +//! by crates/spock-runtime/tests/filter.rs. + +/// A widget: one column per filterable shape. Nothing here means anything — +/// the columns are named for the machinery they exercise. +table widget { + key id: uuid = auto + /// text: ordering ties, `ilike` patterns, `%`/`_` literals, case folding. + label: text + /// int: numeric comparison and the keyset probe. Deliberately non-unique + /// (three rows share rank 1) so the forced pk tiebreak has something to do. + rank: int + /// float: real-valued comparison. + score: float + /// bool: `is.true`/`_eq: true` filtering (stored as INTEGER 0/1). + active: bool + /// a closed set (RFD 0013): membership is validated, an off-set operand is + /// a `type_mismatch`, and the field stays a GraphQL `String`. + kind: "alpha" | "beta" | "gamma" + /// nullable text: the NULL law (`_eq: null` refused) and NULLS placement. + note: text? + /// timestamp: range and ordering over canonical (lexically-sortable) values. + made_at: timestamp + /// a self-reference: FK-by-key filtering and the reverse collection + /// `widget_by_parent`. + parent: widget? +} + +/// A composite-key edge — a table with no single key, to prove filtering and +/// the forced total order still work when the tiebreak is a compound key. +table edge { + key (src, dst) + src: widget + dst: widget + weight: int +} + +seed { + // Three rank-1 rows (a, b, c) create an ordering tie: paged by rank they + // must never skip or duplicate, which only the forced pk tiebreak buys. + // `b` omits `note` (→ null). Labels carry a literal `%`, a literal `_`, and + // a non-ASCII accent to probe `ilike` escaping and case folding. + a = widget { label: "alpha one", rank: 1, score: 1.5, active: true, kind: "alpha", note: "first", made_at: "2026-01-01T00:00:00Z" } + b = widget { label: "Alpha Two", rank: 1, score: 2.5, active: false, kind: "alpha", made_at: "2026-01-02T00:00:00Z" } + c = widget { label: "beta_three", rank: 1, score: 3.5, active: true, kind: "beta", note: "3rd", made_at: "2026-01-03T00:00:00Z" } + d = widget { label: "50% off", rank: 2, score: 0.5, active: false, kind: "beta", note: "sale", made_at: "2026-01-04T00:00:00Z", parent: a } + e = widget { label: "café", rank: 3, score: 10.0, active: true, kind: "gamma", note: "accent", made_at: "2026-01-05T00:00:00Z", parent: a } + + edge { src: a, dst: b, weight: 5 } + edge { src: a, dst: c, weight: 3 } + edge { src: b, dst: c, weight: 8 } +} From 7297a9673fc98defc56b37e593e31bbca150afcf Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 13 Jul 2026 16:46:16 +0900 Subject: [PATCH 2/2] fix: address CodeRabbit review on the filter read half MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - filter.rs: cap REST logical-group nesting at 32 (MAX_FILTER_DEPTH) — an unbounded ?and=(and(and(...))) could overflow the stack / burn superlinear work before returning bad_request. Mirrors GraphQL's .limit_depth(32). - filter.rs: reject `ilike` on non-text columns (type_mismatch) — mirrors the GraphQL side where `_ilike` lives only on String; SQLite would coerce silently. - graphql.rs: reject multi-key `order_by` objects — serde_json::Map iterates in sorted key order, so {b: desc, a: asc} silently reordered to a,b; the list form carries multiple terms. - studio/table-view.tsx: clear a filter's value when its column changes (a wrong-typed value would type_mismatch and show a non-option in set/bool dropdowns); drop stale in-flight load() responses via the lastQuery marker so a slow older request can't clobber fresher rows. - tests: add _and GraphQL case; add REST refusals for ilike-on-int, deep nesting, and multi-key order_by. All green: 201 tests + clippy + fmt; studio tsc/oxlint/pnpm build; column-clear verified live. --- crates/spock-runtime/src/filter.rs | 37 +++++++++++++++---- crates/spock-runtime/src/graphql.rs | 9 +++++ .../studio/src/views/table-view.tsx | 7 +++- crates/spock-runtime/tests/filter.rs | 29 +++++++++++++++ crates/spock-runtime/tests/graphql.rs | 11 ++++++ 5 files changed, 85 insertions(+), 8 deletions(-) diff --git a/crates/spock-runtime/src/filter.rs b/crates/spock-runtime/src/filter.rs index 6cad0ab..5a8d3da 100644 --- a/crates/spock-runtime/src/filter.rs +++ b/crates/spock-runtime/src/filter.rs @@ -31,6 +31,11 @@ pub const MAX_OFFSET: i64 = 10_000; /// count — across every leaf, not any one `IN` list — would exceed it is a /// `bad_request`, never a `prepare()`-time 500. pub const MAX_FILTER_PARAMS: usize = 30_000; +/// Cap on nested logical-group depth (`?and=(and(and(…)))`). Unbounded +/// recursion risks a stack overflow — and the substring rebuild at each level +/// is superlinear — before the parse ever reaches `bad_request`. Mirrors the +/// GraphQL frontend's `.limit_depth(32)`. +pub const MAX_FILTER_DEPTH: usize = 32; /// The single-value comparison operators. `_in`/`_nin` (list) and `_is_null` /// have their own [`Predicate`] variants; this is the closed set that maps @@ -309,13 +314,13 @@ pub fn parse_rest( "column projection (`select`) is not supported in v0", )); } - "and" => conds.push(Predicate::And(parse_group(contract, table, val)?)), - "or" => conds.push(Predicate::Or(parse_group(contract, table, val)?)), + "and" => conds.push(Predicate::And(parse_group(contract, table, val, 1)?)), + "or" => conds.push(Predicate::Or(parse_group(contract, table, val, 1)?)), "not.and" => conds.push(Predicate::Not(Box::new(Predicate::And(parse_group( - contract, table, val, + contract, table, val, 1, )?)))), "not.or" => conds.push(Predicate::Not(Box::new(Predicate::Or(parse_group( - contract, table, val, + contract, table, val, 1, )?)))), // a plain column filter: `?col=op.val` col => conds.push(parse_column_op(contract, table, col, val)?), @@ -414,7 +419,17 @@ fn split_top_level(s: &str) -> Vec { /// Parse a logical group body: `(m1,m2,…)`. Each member is either a nested /// group (`and(…)`, `or(…)`, `not.and(…)`, `not.or(…)`) or a column condition /// `col.op.val`. -fn parse_group(contract: &Contract, table: &Table, raw: &str) -> Result, ApiError> { +fn parse_group( + contract: &Contract, + table: &Table, + raw: &str, + depth: usize, +) -> Result, ApiError> { + if depth > MAX_FILTER_DEPTH { + return Err(ApiError::bad_request(format!( + "filter nesting exceeds the depth limit of {MAX_FILTER_DEPTH}" + ))); + } let inner = raw .strip_prefix('(') .and_then(|s| s.strip_suffix(')')) @@ -423,7 +438,7 @@ fn parse_group(contract: &Contract, table: &Table, raw: &str) -> Result Result { for (prefix, negated, is_or) in [ ("not.and(", true, false), @@ -443,7 +459,7 @@ fn parse_group_member( ] { if let Some(rest) = member.strip_prefix(prefix) { let body = format!("({rest}"); // restore the paren the prefix ate - let members = parse_group(contract, table, &body)?; + let members = parse_group(contract, table, &body, depth + 1)?; let group = if is_or { Predicate::Or(members) } else { @@ -500,6 +516,13 @@ fn parse_column_op( "lt" => cmp(CmpOp::Lt, false), "lte" => cmp(CmpOp::Lte, false), "ilike" => { + // `ilike` is text-only, mirroring the GraphQL side where `_ilike` + // lives only on `String_comparison_exp` (text + closed sets). On a + // non-text column SQLite would silently coerce rather than error, so + // refuse loudly instead (§9). + if !matches!(contract.value_type(&field.ty), Type::Text | Type::Set { .. }) { + return Err(ApiError::type_mismatch(&table.name, col, "a text column")); + } // PostgREST aliases `*` → `%` to dodge URL-encoding (§6 deviation) let pattern = value.replace('*', "%"); Ok(Predicate::Cmp { diff --git a/crates/spock-runtime/src/graphql.rs b/crates/spock-runtime/src/graphql.rs index b898857..4860ec1 100644 --- a/crates/spock-runtime/src/graphql.rs +++ b/crates/spock-runtime/src/graphql.rs @@ -885,6 +885,15 @@ fn parse_order_json(table: &Table, json: &Json) -> Result, Ap let obj = el .as_object() .ok_or_else(|| ApiError::bad_request("each `order_by` entry is an object"))?; + // `serde_json::Map` iterates in sorted key order, so a multi-key object + // would silently reorder terms (`{b: desc, a: asc}` → a, b). Require one + // key per object; multiple sort terms use the list form (RFD 0021 §7). + if obj.len() != 1 { + return Err(ApiError::bad_request( + "each `order_by` entry must have exactly one {column: asc|desc}; \ + use the list form `[{a: asc}, {b: desc}]` for multiple terms", + )); + } for (col, dirval) in obj { if table.field(col).is_none() { return Err(ApiError::unknown_field(&table.name, col)); diff --git a/crates/spock-runtime/studio/src/views/table-view.tsx b/crates/spock-runtime/studio/src/views/table-view.tsx index 7f9ca19..14fafb5 100644 --- a/crates/spock-runtime/studio/src/views/table-view.tsx +++ b/crates/spock-runtime/studio/src/views/table-view.tsx @@ -143,6 +143,9 @@ export class TableView extends Component<{ name: string }, State> { const qs = buildQuery(filters, sorts, limit, offset) this.lastQuery = qs const res = await api(`/rest/v1/${encodeURIComponent(table.name)}?${qs}`, this.context.actor) + // a newer load() may have started (rewriting lastQuery) while this request + // was in flight — drop the now-stale response so it can't clobber fresh rows + if (qs !== this.lastQuery) return if (res.status !== 200) { // surface the floor's refusal (unknown_field / type_mismatch / bad_request) const msg = isErrorBody(res.body) @@ -189,7 +192,9 @@ export class TableView extends Component<{ name: string }, State> { else this.setState(next) // typing: update the draft, defer the fetch to commit } private fSetColumn = (id: number, column: string) => - this.mapRule(id, (r) => ({ ...r, column }), true) + // clear the value: it may not match the new column's type (a bool/set + // dropdown would show a non-option, and the re-fetch would type_mismatch) + this.mapRule(id, (r) => ({ ...r, column, value: "" }), true) private fSetOp = (id: number, op: string) => this.mapRule(id, (r) => ({ ...r, op }), true) private fDraftValue = (id: number, value: string) => this.mapRule(id, (r) => ({ ...r, value }), false) diff --git a/crates/spock-runtime/tests/filter.rs b/crates/spock-runtime/tests/filter.rs index 122ec1a..ae8c199 100644 --- a/crates/spock-runtime/tests/filter.rs +++ b/crates/spock-runtime/tests/filter.rs @@ -299,6 +299,35 @@ async fn refusals() { ); let r = gql(&base, "{ widget(offset: 99999) { label } }").await; assert_eq!(r["errors"][0]["extensions"]["code"], "bad_request"); + + // `ilike` on a non-text column is refused (mirrors the GraphQL side, where + // `_ilike` exists only on `String`) — SQLite would otherwise coerce silently + let (status, b) = get(&base, "/rest/v1/widget?rank=ilike.5*").await; + assert_eq!( + (status, b["error"]["code"].as_str().unwrap()), + (422, "type_mismatch") + ); + + // deeply nested logical groups are refused before they can overflow the + // stack (§8.7) — 40 levels is past the depth ceiling of 32 + let mut nested = "rank.gt.0".to_string(); + for _ in 0..40 { + nested = format!("and({nested})"); + } + let (status, b) = get(&base, &format!("/rest/v1/widget?and=({nested})")).await; + assert_eq!( + (status, b["error"]["code"].as_str().unwrap()), + (400, "bad_request") + ); + + // a multi-key `order_by` object is refused: `serde_json::Map` would sort the + // keys and silently reorder terms — the list form carries multiple terms + let r = gql( + &base, + "{ widget(order_by: {rank: asc, score: desc}) { label } }", + ) + .await; + assert_eq!(r["errors"][0]["extensions"]["code"], "bad_request"); } /// FINDING (the honest one): a bare `_gt` keyset over a non-unique sort column diff --git a/crates/spock-runtime/tests/graphql.rs b/crates/spock-runtime/tests/graphql.rs index 238a31b..011ff7c 100644 --- a/crates/spock-runtime/tests/graphql.rs +++ b/crates/spock-runtime/tests/graphql.rs @@ -947,6 +947,17 @@ async fn the_filter_surface() { .await; assert_no_errors(&resp); assert_eq!(resp["data"]["post"].as_array().unwrap().len(), 2); + // _and: the intersection — only "golden hour" matches both patterns + let resp = gql( + &base, + r#"{ post(where: {_and: [{caption: {_ilike: "%golden%"}}, {caption: {_ilike: "%hour%"}}]}) { caption } }"#, + Value::Null, + ) + .await; + assert_no_errors(&resp); + let posts = resp["data"]["post"].as_array().unwrap(); + assert_eq!(posts.len(), 1); + assert_eq!(posts[0]["caption"], "golden hour"); let resp = gql( &base, r#"{ user(where: {_not: {username: {_eq: "maya"}}}) { username } }"#,