quarb_sql/export.rs
1//! The reverse direction: Quarb → SQL.
2//!
3//! Walks the query's *reflection arbor* — the locked vocabulary is
4//! the stable surface for query-rewriting tooling — and emits an
5//! equivalent `SELECT` statement, refusing what the SQL query core
6//! cannot express. Fragments and pure macros expand before
7//! reflection, so they translate for free.
8//!
9//! The translatable subset mirrors the importer: `/table/*`
10//! branches with predicates (→ `WHERE`), witness joins (`<=>` with
11//! `$*1` equality → `JOIN ... ON`, remaining conditions → `WHERE`,
12//! `$*k` select fields qualified), `rec(...)` select lists,
13//! whole-table and grouped aggregates (`GROUP BY`, a filter after
14//! the reduction → `HAVING`), `sort_by`/`reverse`/`top` →
15//! `ORDER BY`/`LIMIT`, `@| [..n]` → `LIMIT`, and single-column
16//! `unique` → `DISTINCT`.
17//!
18//! Deliberately refused: `-->` resolution chains — the foreign-key
19//! targets live in the *schema*, not the query text, so a chain
20//! cannot be compiled to a join without a database at hand; spell
21//! the join explicitly with `<=>` to export it. Also refused:
22//! registers beyond the grouped-aggregate pattern, windows,
23//! captures, and regex matching (`LIKE` dialects disagree).
24//!
25//! Notes carry the standing divergences: truthiness (`[::c]` →
26//! `IS NOT NULL`, but Quarb also treats `0` and `''` as falsy),
27//! `*=` → `LIKE` case-folding dialects, and join cardinality
28//! (Quarb's existential binding never multiplies rows; SQL `JOIN`
29//! does when several left rows match).
30
31use crate::{SqlError, Translation};
32use quarb::reflect::QueryArbor;
33use quarb::{AstAdapter, NodeId, Value};
34
35/// Translate a Quarb query to a SQL `SELECT` statement.
36pub fn export(quarb: &str) -> Result<Translation, SqlError> {
37 refuse_marker(quarb)?;
38 let arbor =
39 QueryArbor::parse(quarb).map_err(|e| SqlError::Syntax(format!("parsing Quarb: {e}")))?;
40 refuse_groups(&arbor)?;
41 let mut ex = Exporter {
42 arbor,
43 notes: Vec::new(),
44 strict: false,
45 dialect: None,
46 from_table: String::new(),
47 join_on_cols: Vec::new(),
48 join_table: None,
49 column_types: ColumnTypes::default(),
50 from_sql: String::new(),
51 join_sql: None,
52 in_on: false,
53 aggregate: false,
54 keyed_hops: false,
55 };
56 let query = ex.query()?;
57 Ok(Translation {
58 query,
59 notes: ex.notes,
60 })
61}
62
63/// The exporter rewrites `__LEFT__` (the join's left table) and
64/// `__AGG__` (the HAVING aggregate) as internal placeholders.
65/// Query text containing either marker would be rewritten inside
66/// its own string literals — and could spoof the emitted SQL — so
67/// such queries stay on the scan path.
68fn refuse_marker(quarb: &str) -> Result<(), SqlError> {
69 for marker in ["__LEFT__", "__AGG__"] {
70 if quarb.contains(marker) {
71 return Err(SqlError::Unsupported(format!(
72 "query text contains the reserved marker \"{marker}\""
73 )));
74 }
75 }
76 Ok(())
77}
78
79/// A pushdown plan: SQL whose execution is provably identical to
80/// the Quarb query's — plus the table whose primary key must order
81/// the rows (`None` for a single aggregate row, where order is
82/// moot). The driver appends the `ORDER BY`, since the key lives in
83/// its catalog.
84pub struct Pushdown {
85 pub sql: String,
86 pub order_table: Option<String>,
87 /// Present when the plan contains a witness JOIN: the joined
88 /// table and the columns its ON equalities bind on it
89 /// (collected structurally from the arbor, so query text
90 /// cannot spoof them).
91 /// The plan is only sound if those columns form a unique key
92 /// of the joined table — each FROM row must find at most one
93 /// witness, else SQL multiplies rows where Quarb's existential
94 /// binding does not. The *driver* must verify against its
95 /// catalog before executing, and fall back to the scan if it
96 /// cannot.
97 pub join_left: Option<(String, Vec<String>)>,
98 /// What the plan takes for granted about column types. Quarb
99 /// compares by value (a numeric-looking text and a number meet
100 /// as numbers, a number and other text never meet); SQL
101 /// compares by the column's type, and the loosely typed engines
102 /// (SQLite, MySQL) coerce by rules of their own. The plan is
103 /// the scan's only where the columns it compares with a number
104 /// are numeric-typed and those it compares with text are not:
105 /// the caller asks its catalog and scans when one is otherwise.
106 /// (A strictly typed engine rejects the mismatch itself, which
107 /// sends the caller to the scan as well.)
108 pub column_types: ColumnTypes,
109 /// The tables the plan reads (the FROM table, then a joined one).
110 pub tables: Vec<String>,
111}
112
113/// The column types a pushed plan assumes (see
114/// [`Pushdown::column_types`]).
115#[derive(Clone, Debug, Default, PartialEq, Eq)]
116pub struct ColumnTypes {
117 /// Columns compared with a numeric literal.
118 pub numeric: Vec<String>,
119 /// Columns compared with a text literal.
120 pub text: Vec<String>,
121 /// Columns the plan extracts a JSON path from on the strength
122 /// of the column's declaration alone (the hop carried no
123 /// `<json>`): the pushed `json_extract` is the scan's only where
124 /// the catalog declares the column json, since the scan grafts
125 /// a leaf only as declared (spec: Grafting).
126 pub json: Vec<String>,
127}
128
129impl ColumnTypes {
130 pub fn is_empty(&self) -> bool {
131 self.numeric.is_empty() && self.text.is_empty() && self.json.is_empty()
132 }
133
134 /// The assumptions a catalog does not bear out: the columns
135 /// compared with a number that are not numeric-typed, and those
136 /// compared with text that are. `catalog` lists every column of
137 /// the tables read, with whether its type is numeric and
138 /// whether it is declared json; names meet without regard to
139 /// case. A column the catalog does not list is unknown, and
140 /// counts as unmet.
141 pub fn unmet(&self, catalog: &[(String, bool, bool)]) -> Vec<String> {
142 let met = |column: &str, pick: fn(&(String, bool, bool)) -> bool, want: bool| {
143 let mut found = catalog
144 .iter()
145 .filter(|(name, ..)| name.eq_ignore_ascii_case(column))
146 .peekable();
147 found.peek().is_some() && found.all(|entry| pick(entry) == want)
148 };
149 let numeric = |e: &(String, bool, bool)| e.1;
150 let json = |e: &(String, bool, bool)| e.2;
151 let mut out: Vec<String> = Vec::new();
152 for (columns, pick, want) in [
153 (&self.numeric, numeric as fn(&(String, bool, bool)) -> bool, true),
154 (&self.text, numeric, false),
155 (&self.json, json, true),
156 ] {
157 for c in columns {
158 if !met(c, pick, want) && !out.contains(c) {
159 out.push(c.clone());
160 }
161 }
162 }
163 out
164 }
165}
166
167/// The target SQL dialect, for the one construct whose emitted
168/// SQL is not portable: a filter that navigates *into* a JSON
169/// column, which each engine extracts with its own operator.
170/// The rest of a pushdown is dialect-agnostic. A query with no
171/// JSON-column filter emits identical SQL regardless of dialect.
172#[derive(Clone, Copy, PartialEq, Eq, Debug)]
173pub enum Dialect {
174 Postgres,
175 MySql,
176 Sqlite,
177 Mssql,
178 Oracle,
179}
180
181/// Attempt the pushdown translation: `Some` only when every
182/// construct in the query is in the verified-safe set. Anything
183/// else — including everything `export` would merely annotate with
184/// a divergence note — returns `None`, and the caller scans.
185///
186/// `dialect` enables JSON-column-path pushdown for that engine
187/// (fixed-path string equality only); `None` keeps such filters
188/// on the client-side graft.
189pub fn pushdown(quarb: &str, dialect: Option<Dialect>) -> Option<Pushdown> {
190 pushdown_explained(quarb, dialect).ok()
191}
192
193/// [`pushdown`], keeping the refusal: the error names the first
194/// construct that kept the query on the scan path.
195pub fn pushdown_explained(quarb: &str, dialect: Option<Dialect>) -> Result<Pushdown, SqlError> {
196 refuse_marker(quarb)?;
197 let arbor =
198 QueryArbor::parse(quarb).map_err(|e| SqlError::Syntax(format!("parsing Quarb: {e}")))?;
199 refuse_groups(&arbor)?;
200 let mut ex = Exporter {
201 arbor,
202 notes: Vec::new(),
203 strict: true,
204 dialect,
205 from_table: String::new(),
206 join_on_cols: Vec::new(),
207 join_table: None,
208 column_types: ColumnTypes::default(),
209 from_sql: String::new(),
210 join_sql: None,
211 in_on: false,
212 aggregate: false,
213 keyed_hops: false,
214 };
215 let sql = ex.query()?;
216 let order_table = if ex.aggregate {
217 None
218 } else {
219 // Rows come back in the driver's document order — the FROM
220 // table's (driver-first correlation).
221 Some(ex.from_table.clone())
222 };
223 let join_left = ex.join_table.clone().map(|t| (t, ex.join_on_cols.clone()));
224 let mut tables = vec![ex.from_table.clone()];
225 tables.extend(ex.join_table.clone());
226 Ok(Pushdown {
227 sql,
228 order_table,
229 join_left,
230 column_types: ex.column_types.clone(),
231 tables,
232 })
233}
234
235/// The dialect-specific SQL that extracts a JSON scalar at a
236/// fixed object path, unquoted to text. `path` holds plain
237/// object keys (identifier-safe, per `json_path`), so no
238/// per-dialect path-escaping is needed. Each engine's operator
239/// returns the value as text and yields NULL for an absent path
240/// or a non-scalar — matching the graft, which excludes those
241/// rows too.
242fn json_extract(dialect: Dialect, qual: Option<&str>, col: &str, path: &[String]) -> String {
243 let qcol = match qual {
244 Some(q) => format!("{q}.{col}"),
245 None => col.to_string(),
246 };
247 match dialect {
248 // `#>>` takes a text-array path and returns text; the
249 // `::jsonb` cast lets it work on json, jsonb, and
250 // text-holding-JSON columns alike (an invalid-JSON row
251 // errors, and the driver falls back to the scan).
252 Dialect::Postgres => format!("({qcol}::jsonb #>> '{{{}}}')", path.join(",")),
253 // JSON_UNQUOTE(JSON_EXTRACT(...)) is the `->>` shorthand
254 // spelled out — portable across MySQL and MariaDB, where
255 // the `->>` operator itself is not.
256 Dialect::MySql => {
257 format!("JSON_UNQUOTE(JSON_EXTRACT({qcol}, '$.{}'))", path.join("."))
258 }
259 Dialect::Sqlite => format!("json_extract({qcol}, '$.{}')", path.join(".")),
260 // JSON_VALUE returns a scalar as text (lax mode: NULL on a
261 // missing path or a non-scalar, no error).
262 Dialect::Mssql | Dialect::Oracle => format!("JSON_VALUE({qcol}, '$.{}')", path.join(".")),
263 }
264}
265
266/// Wrap a JSON-path condition in the dialect's validity guard,
267/// where a cheap one exists. SQLite and MySQL error the whole
268/// statement on one malformed-JSON row — which turned every
269/// pushdown over a dirty table into a silent fallback scan — and
270/// the graft excludes such rows anyway (no parse, no subtree), so
271/// the guard is observationally identical. SQL Server gets ISJSON;
272/// Oracle's JSON_VALUE is lax already; PostgreSQL has no guard
273/// short of the ::jsonb cast itself (an invalid row errors and the
274/// caller falls back to the scan).
275fn json_valid_guard(dialect: Dialect, qual: Option<&str>, col: &str, cond: String) -> String {
276 let qcol = match qual {
277 Some(q) => format!("{q}.{col}"),
278 None => col.to_string(),
279 };
280 match dialect {
281 Dialect::Sqlite => format!("(json_valid({qcol}) AND {cond})"),
282 Dialect::MySql => format!("(JSON_VALID({qcol}) AND {cond})"),
283 Dialect::Mssql => format!("(ISJSON({qcol}) = 1 AND {cond})"),
284 Dialect::Postgres | Dialect::Oracle => format!("({cond})"),
285 }
286}
287
288/// The dialect's JSON type probe at a fixed path — non-NULL iff
289/// the path exists. A JSON `null` value reports its type ('null'),
290/// so existence still sees it, matching the graft, where a
291/// null-valued key is a node. Only the dialects with a native
292/// probe participate.
293fn json_type_probe(
294 dialect: Dialect,
295 qual: Option<&str>,
296 col: &str,
297 path: &[String],
298) -> Option<String> {
299 let qcol = match qual {
300 Some(q) => format!("{q}.{col}"),
301 None => col.to_string(),
302 };
303 match dialect {
304 Dialect::Sqlite => Some(format!("json_type({qcol}, '$.{}')", path.join("."))),
305 Dialect::Postgres => Some(format!(
306 "jsonb_typeof({qcol}::jsonb #> '{{{}}}')",
307 path.join(",")
308 )),
309 Dialect::MySql | Dialect::Mssql | Dialect::Oracle => None,
310 }
311}
312
313/// Flip a comparison for a swapped operand order (`lit OP path` →
314/// `path OP' lit`).
315fn flip_op(op: &str) -> String {
316 match op {
317 "<" => ">".into(),
318 "<=" => ">=".into(),
319 ">" => "<".into(),
320 ">=" => "<=".into(),
321 other => other.into(),
322 }
323}
324
325/// SQL keywords that must not appear as a bare identifier or `AS`
326/// alias — quoting them portably differs by dialect, so strict
327/// mode refuses and export mode quotes with double quotes plus a
328/// note.
329const SQL_KEYWORDS: &[&str] = &[
330 "all",
331 "and",
332 "as",
333 "asc",
334 "by",
335 "case",
336 "cross",
337 "desc",
338 "distinct",
339 "else",
340 "end",
341 "except",
342 "exists",
343 "from",
344 "group",
345 "having",
346 "in",
347 "index",
348 "inner",
349 "intersect",
350 "into",
351 "is",
352 "join",
353 "left",
354 "like",
355 "limit",
356 "not",
357 "null",
358 "offset",
359 "on",
360 "or",
361 "order",
362 "outer",
363 "right",
364 "select",
365 "set",
366 "table",
367 "then",
368 "union",
369 "unique",
370 "update",
371 "using",
372 "values",
373 "when",
374 "where",
375];
376
377/// A bare SQL identifier, portable across the target dialects: a
378/// letter or underscore, then letters, digits, and underscores,
379/// and not a reserved word.
380fn is_plain_ident(name: &str) -> bool {
381 !name.is_empty()
382 && name
383 .chars()
384 .next()
385 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
386 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
387 && !SQL_KEYWORDS.contains(&name.to_ascii_lowercase().as_str())
388}
389
390/// The SELECT under construction.
391#[derive(Default)]
392struct Select {
393 select: Vec<String>,
394 distinct: bool,
395 from: String,
396 join: Option<(String, String)>, // (table, ON condition)
397 /// The join keeps every FROM row (`<=>?`, the outer
398 /// correlation): a LEFT JOIN.
399 join_outer: bool,
400 wheres: Vec<String>,
401 group_by: Option<String>,
402 having: Option<String>,
403 order_by: Option<(String, bool)>, // (expr, desc)
404 limit: Option<String>,
405}
406
407impl Select {
408 fn render(&self) -> String {
409 let mut out = String::from("SELECT ");
410 if self.distinct {
411 out.push_str("DISTINCT ");
412 }
413 if self.select.is_empty() {
414 out.push('*');
415 } else {
416 out.push_str(&self.select.join(", "));
417 }
418 out.push_str(&format!(" FROM {}", self.from));
419 if let Some((t, on)) = &self.join {
420 let kind = if self.join_outer { "LEFT JOIN" } else { "JOIN" };
421 out.push_str(&format!(" {kind} {t} ON {on}"));
422 }
423 if !self.wheres.is_empty() {
424 out.push_str(&format!(" WHERE {}", self.wheres.join(" AND ")));
425 }
426 if let Some(g) = &self.group_by {
427 out.push_str(&format!(" GROUP BY {g}"));
428 }
429 if let Some(h) = &self.having {
430 out.push_str(&format!(" HAVING {h}"));
431 }
432 if let Some((e, desc)) = &self.order_by {
433 out.push_str(&format!(" ORDER BY {e}"));
434 if *desc {
435 out.push_str(" DESC");
436 }
437 }
438 if let Some(n) = &self.limit {
439 out.push_str(&format!(" LIMIT {n}"));
440 }
441 out
442 }
443}
444
445/// A partial-pushdown plan: the leading row predicates as a WHERE
446/// clause for `table`'s fetch. The caller runs the *original* query
447/// against the filtered adapter — the engine re-applies the pushed
448/// predicates (a no-op on pre-filtered rows), so no rewriting.
449pub struct Partial {
450 pub table: String,
451 pub where_sql: String,
452 /// As [`Pushdown::column_types`]: the prefilter keeps every row
453 /// the scan would only where these assumptions hold.
454 pub column_types: ColumnTypes,
455}
456
457/// Attempt a partial pushdown: `Some` when the query's leading row
458/// predicates translate strictly and the rest of the query provably
459/// cannot observe the filtering. The gates, each with its reason:
460/// a single `/table/*` branch with no correlations (other shapes
461/// address other data); only the *leading* run of expression
462/// predicates pushes (a positional predicate before an expression
463/// one sees unfiltered rows on the scan path); the table's name
464/// appears exactly once among the query's steps (a second reach —
465/// `^`-anchored subcontexts, self-references — would see the
466/// filtered subset); no crosslink or resolution axes anywhere
467/// (backlinks and reverse resolution into the table would too);
468/// and no `:` / `:::` metadata anywhere (a filtered `:::n-rows`
469/// would lie).
470pub fn partial_pushdown(quarb: &str, dialect: Option<Dialect>) -> Option<Partial> {
471 partial_pushdown_explained(quarb, dialect).ok()
472}
473
474/// [`partial_pushdown`], keeping the refusal reason. `dialect`
475/// enables the JSON-column prefilters — the strict fixed-path
476/// string equality, plus the relaxed superset forms (numeric
477/// comparisons, existence) that are safe here and only here,
478/// because the caller re-runs the original query over the
479/// fetched subset.
480pub fn partial_pushdown_explained(
481 quarb: &str,
482 dialect: Option<Dialect>,
483) -> Result<Partial, SqlError> {
484 partial_pushdown_with(quarb, dialect, false)
485}
486
487/// [`partial_pushdown_explained`] for an adapter that reads hops by
488/// key and filters only the table's listing
489/// (`RelationalModel::with_filtered_listing`): crosslinks and
490/// resolutions no longer refuse the prefilter, since a hop into the
491/// filtered table reads its row unfiltered. What still refuses: a
492/// second navigation into the table, and a `<--` or `=>` scope that
493/// could list it.
494pub fn partial_pushdown_keyed(quarb: &str, dialect: Option<Dialect>) -> Result<Partial, SqlError> {
495 partial_pushdown_with(quarb, dialect, true)
496}
497
498fn partial_pushdown_with(
499 quarb: &str,
500 dialect: Option<Dialect>,
501 keyed_hops: bool,
502) -> Result<Partial, SqlError> {
503 refuse_marker(quarb)?;
504 let arbor =
505 QueryArbor::parse(quarb).map_err(|e| SqlError::Syntax(format!("parsing Quarb: {e}")))?;
506 refuse_groups(&arbor)?;
507 let mut ex = Exporter {
508 arbor,
509 notes: Vec::new(),
510 strict: true,
511 dialect,
512 from_table: String::new(),
513 join_on_cols: Vec::new(),
514 join_table: None,
515 column_types: ColumnTypes::default(),
516 from_sql: String::new(),
517 join_sql: None,
518 in_on: false,
519 aggregate: false,
520 keyed_hops,
521 };
522 ex.partial()
523}
524
525/// Refuse a query carrying path-pattern groups: neither the SQL
526/// translation nor the pushdown safe set covers them, and the shape
527/// checks below count only `step` children — an unguarded group
528/// would silently vanish from the translation. Refused queries fall
529/// back to the scan path, which evaluates groups correctly.
530fn refuse_groups(arbor: &QueryArbor) -> Result<(), SqlError> {
531 let mut stack = vec![arbor.root()];
532 while let Some(n) = stack.pop() {
533 if arbor.name(n).as_deref() == Some("group") {
534 return Err(SqlError::Unsupported(
535 "path patterns (groups and quantifiers)".into(),
536 ));
537 }
538 stack.extend(arbor.children(n));
539 }
540 Ok(())
541}
542
543struct Exporter {
544 arbor: QueryArbor,
545 notes: Vec<String>,
546 /// Pushdown mode: refuse every construct whose SQL semantics
547 /// are not provably identical to Quarb's (LIKE case folding,
548 /// truthiness, group/distinct/sort ordering).
549 strict: bool,
550 /// The target dialect for JSON-column-path pushdown; `None`
551 /// leaves such filters on the client-side graft.
552 dialect: Option<Dialect>,
553 from_table: String,
554 join_on_cols: Vec<String>,
555 join_table: Option<String>,
556 /// What the comparisons assume of column types (see
557 /// [`Pushdown::column_types`]).
558 column_types: ColumnTypes,
559 from_sql: String,
560 join_sql: Option<String>,
561 in_on: bool,
562 aggregate: bool,
563 /// The adapter reads hops by key (the pushed-down WHERE filters
564 /// the table's listing only), so crosslinks and resolutions are
565 /// safe beside the prefilter.
566 keyed_hops: bool,
567}
568
569impl Exporter {
570 fn kids(&self, n: NodeId, kind: &str) -> Vec<NodeId> {
571 self.arbor
572 .children(n)
573 .into_iter()
574 .filter(|&c| self.arbor.name(c).as_deref() == Some(kind))
575 .collect()
576 }
577
578 fn kid(&self, n: NodeId, kind: &str) -> Option<NodeId> {
579 self.kids(n, kind).into_iter().next()
580 }
581
582 fn prop(&self, n: NodeId, key: &str) -> Option<Value> {
583 self.arbor.property(n, key)
584 }
585
586 fn prop_s(&self, n: NodeId, key: &str) -> String {
587 self.prop(n, key).map(|v| v.to_string()).unwrap_or_default()
588 }
589
590 fn kind(&self, n: NodeId) -> String {
591 self.arbor.name(n).unwrap_or_default()
592 }
593
594 /// Whether `n` is a bare `null` literal operand.
595 fn is_null_literal(&self, n: NodeId) -> bool {
596 self.kind(n) == "literal" && self.prop_s(n, "type") == "null"
597 }
598
599 /// The partial-pushdown analysis (see [`partial_pushdown`]).
600 fn partial(&mut self) -> Result<Partial, SqlError> {
601 let root = self.arbor.root();
602 let q = self
603 .kid(root, "query")
604 .ok_or_else(|| SqlError::Unsupported("empty query".into()))?;
605 // A correlation does not forbid the prefilter: the driver's
606 // own predicates restrict the driver's rows whatever the join
607 // then binds. What must not happen is the join (or anything
608 // else) reaching the filtered table a second time — a
609 // self-join would see only the filtered rows — and the
610 // whole-query gates below refuse exactly that: they walk the
611 // correlation entries too.
612 let branches = self.kids(q, "branch");
613 if branches.len() != 1 {
614 return Err(SqlError::Unsupported("a branch union".into()));
615 }
616 let (table, preds) = self.table_branch_lead(branches[0], self.keyed_hops)?;
617
618 // Whole-query gates.
619 let all = self.walk_all(root);
620 let mut table_mentions = 0;
621 for n in &all {
622 match self.kind(*n).as_str() {
623 "step" => {
624 let axis = self.prop_s(*n, "axis");
625 // A crosslink, a resolution or a value match may
626 // land in (or search) the filtered table and would
627 // see only its filtered rows: `=>` and the scoped
628 // `<--` carry their scope as text, so the table
629 // count below cannot see them.
630 if matches!(axis.as_str(), "->" | "<-" | "--" | "-->" | "<--" | "=>") {
631 // Where hops are read by key they are exact
632 // beside the prefilter. A scope is a listing,
633 // though: it is safe only when it plainly
634 // lists another table (`/other/…`), and an
635 // unscoped `=>` walks every table.
636 let scope = self.prop_s(*n, "scope");
637 let lists_other_table = scope
638 .strip_prefix('/')
639 .and_then(|rest| rest.split(['/', '[', '<']).next())
640 .is_some_and(|first| {
641 !first.is_empty()
642 && first != table
643 && first.chars().all(|c| c.is_alphanumeric() || c == '_')
644 });
645 let safe = self.keyed_hops
646 && if scope.is_empty() {
647 axis != "=>"
648 } else {
649 lists_other_table
650 };
651 if !safe {
652 return Err(SqlError::Unsupported(
653 "crosslink/resolution axes could reach \
654 the filtered table"
655 .into(),
656 ));
657 }
658 }
659 if self.prop_s(*n, "matcher") == table {
660 table_mentions += 1;
661 }
662 }
663 "projection" if self.prop_s(*n, "kind") != "property" => {
664 return Err(SqlError::Unsupported(
665 "metadata would observe the filtering \
666 (:::n-rows, :index)"
667 .into(),
668 ));
669 }
670 _ => {}
671 }
672 }
673 if table_mentions != 1 {
674 return Err(SqlError::Unsupported(
675 "the table is reached more than once".into(),
676 ));
677 }
678
679 // The leading run of expression predicates. Strict
680 // translation first; a predicate the strict translator
681 // refuses tries the relaxed prefilter ladder — superset
682 // semantics: the fetched set may hold extra rows, because
683 // the caller re-runs the ORIGINAL query, which re-applies
684 // every predicate; it must never lose a matching row. An
685 // untranslatable predicate is skipped, not fatal — a
686 // shorter conjunction is still a superset filter. A
687 // positional predicate (index/range) ends the run:
688 // predicates after it filter a positionally-selected
689 // subsequence.
690 let mut conds = Vec::new();
691 for p in preds {
692 if self.prop_s(p, "kind") != "expr" {
693 break;
694 }
695 match self.predicate_cond(p, None) {
696 Ok(c) => conds.push(c),
697 Err(_) => {
698 if let Some(c) = self.prefilter_cond(p) {
699 conds.push(c);
700 }
701 }
702 }
703 }
704 if conds.is_empty() {
705 return Err(SqlError::Unsupported(
706 "no leading expression predicates to push".into(),
707 ));
708 }
709 Ok(Partial {
710 table,
711 where_sql: conds.join(" AND "),
712 column_types: self.column_types.clone(),
713 })
714 }
715
716 /// The relaxed prefilter for one refused expression predicate:
717 /// the AND of whatever conjuncts translate, each a superset of
718 /// the rows its Quarb counterpart keeps. None when nothing
719 /// does — the predicate stays entirely on the engine.
720 fn prefilter_cond(&mut self, p: NodeId) -> Option<String> {
721 let parts: Vec<String> = self
722 .arbor
723 .children(p)
724 .into_iter()
725 .filter_map(|c| {
726 self.pred_expr(c, None)
727 .ok()
728 .or_else(|| self.prefilter_expr(c))
729 })
730 .collect();
731 if parts.is_empty() {
732 None
733 } else {
734 Some(parts.join(" AND "))
735 }
736 }
737
738 /// One conjunct's superset prefilter — fixed-path JSON shapes
739 /// only, per dialect. Every form here is proven to keep every
740 /// row the graft would match (extras are fine; the engine
741 /// re-checks). The proofs lean on three graft facts: absent
742 /// paths match no comparison; `value_eq`/`value_cmp` coerce
743 /// numeric-looking strings numerically; and Str-to-Str
744 /// ordering is bytewise.
745 fn prefilter_expr(&mut self, e: NodeId) -> Option<String> {
746 let dialect = self.dialect?;
747 match self.kind(e).as_str() {
748 // A bare truthy path: existence (with `::`, value
749 // truthiness — existence is its superset). The type
750 // probe sees JSON null too, as the graft does.
751 "path" => {
752 let (col, path) = self.json_path_loose(e)?;
753 self.note_json_column(e, &col);
754 let probe = json_type_probe(dialect, None, &col, &path)?;
755 Some(json_valid_guard(
756 dialect,
757 None,
758 &col,
759 format!("{probe} IS NOT NULL"),
760 ))
761 }
762 "compare" => {
763 let op = self.prop_s(e, "op");
764 let kids = self.arbor.children(e);
765 if kids.len() != 2 {
766 return None;
767 }
768 for (pi, li) in [(0usize, 1usize), (1, 0)] {
769 let Some((col, path)) = self.json_path(kids[pi]) else {
770 continue;
771 };
772 if self.kind(kids[li]) != "literal" {
773 continue;
774 }
775 let ty = self.prop_s(kids[li], "type");
776 if ty == "null" {
777 return None;
778 }
779 let raw = self
780 .prop(kids[li], "value")
781 .map(|v| v.to_string())
782 .unwrap_or_default();
783 let op = if pi == 0 { op.clone() } else { flip_op(&op) };
784 self.note_json_column(kids[pi], &col);
785 return self.json_prefilter(dialect, &col, &path, &op, &ty, &raw);
786 }
787 None
788 }
789 _ => None,
790 }
791 }
792
793 /// Build the dialect's superset prefilter for
794 /// `json_path OP literal`.
795 fn json_prefilter(
796 &self,
797 d: Dialect,
798 col: &str,
799 path: &[String],
800 op: &str,
801 lit_ty: &str,
802 raw: &str,
803 ) -> Option<String> {
804 if !matches!(op, "=" | "!=" | "<" | "<=" | ">" | ">=") {
805 return None;
806 }
807 // `!=` matches every present path whose value isn't the
808 // literal — including JSON null and cross-typed values —
809 // so its floor is existence.
810 if op == "!=" {
811 let probe = json_type_probe(d, None, col, path)?;
812 return Some(json_valid_guard(
813 d,
814 None,
815 col,
816 format!("{probe} IS NOT NULL"),
817 ));
818 }
819 let numeric_lit = raw.trim().parse::<f64>().is_ok_and(|f| f.is_finite())
820 && (lit_ty != "text" || !raw.trim().is_empty());
821 if numeric_lit {
822 let num: f64 = raw.trim().parse().ok()?;
823 // Past 2^53 the engines' exact integers and the
824 // graft's f64 part ways at the boundary.
825 if num.abs() >= 9_007_199_254_740_992.0 {
826 return None;
827 }
828 let lit = raw.trim();
829 match d {
830 // Typed extract: JSON numbers compare numerically
831 // (both sides parse the same source text to f64);
832 // string values escape through the type probe —
833 // the graft may coerce them, the server must not
834 // drop them.
835 Dialect::Sqlite => {
836 let ex = json_extract(d, None, col, path);
837 let ty = json_type_probe(d, None, col, path)?;
838 Some(json_valid_guard(
839 d,
840 None,
841 col,
842 format!("({ex} {op} {lit} OR {ty} = 'text')"),
843 ))
844 }
845 // float8 is the graft's f64; jsonb numbers print
846 // exactly, so the cast parses the same decimal.
847 // Strings escape; everything else can't match.
848 Dialect::Postgres => {
849 let text = json_extract(d, None, col, path);
850 let ty = json_type_probe(d, None, col, path)?;
851 Some(format!(
852 "(CASE WHEN {ty} = 'number' THEN ({text})::float8 {op} {lit} \
853 WHEN {ty} = 'string' THEN true ELSE false END)"
854 ))
855 }
856 Dialect::MySql | Dialect::Mssql | Dialect::Oracle => None,
857 }
858 } else if lit_ty == "text" {
859 if raw.contains('\'') || raw.contains('\\') {
860 return None;
861 }
862 match d {
863 // Typed extract again: string values order bytewise
864 // on both sides; numbers and booleans sit below
865 // text in SQLite's cross-type order, so `<` keeps
866 // them (extras, re-checked) and `>` drops them
867 // (the graft refuses to order Str against them).
868 Dialect::Sqlite => {
869 let ex = json_extract(d, None, col, path);
870 Some(json_valid_guard(d, None, col, format!("{ex} {op} '{raw}'")))
871 }
872 // Text ordering elsewhere runs into collation; the
873 // engine keeps those.
874 _ => None,
875 }
876 } else {
877 None
878 }
879 }
880
881 /// [`json_path`], loosened for existence: the projection may
882 /// be absent (a bare structural path) as well as the bare
883 /// `::`.
884 /// Whether a JSON path under a column reads the column as JSON
885 /// on the strength of its declaration alone: the column hop
886 /// (the first step) carries no `<json>` trait. Such a column is
887 /// an assumption of the plan ([`ColumnTypes::json`]); with the
888 /// trait the graft is declared on the hop and holds on both
889 /// roads.
890 fn note_json_column(&mut self, o: NodeId, col: &str) {
891 let declared_on_hop = self.kids(o, "step").first().is_some_and(|s| {
892 self.kids(*s, "trait").iter().any(|t| {
893 matches!(self.prop(*t, "alts"), Some(Value::List(alts))
894 if alts.iter().any(|a| matches!(a, Value::Str(x) if x == "json")))
895 })
896 });
897 if !declared_on_hop && !self.column_types.json.iter().any(|c| c == col) {
898 self.column_types.json.push(col.to_string());
899 }
900 }
901
902 fn json_path_loose(&self, o: NodeId) -> Option<(String, Vec<String>)> {
903 if self.json_path(o).is_some() {
904 return self.json_path(o);
905 }
906 if self.kind(o) != "path" || self.kid(o, "projection").is_some() {
907 return None;
908 }
909 let steps = self.kids(o, "step");
910 if steps.len() < 2 {
911 return None;
912 }
913 let mut names = Vec::with_capacity(steps.len());
914 for s in &steps {
915 if self.prop_s(*s, "axis") != "/"
916 || self.prop_s(*s, "matcher-kind") != "name"
917 || self.kid(*s, "predicate").is_some()
918 {
919 return None;
920 }
921 let name = self.prop_s(*s, "matcher");
922 let plain = !name.is_empty()
923 && name
924 .chars()
925 .next()
926 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
927 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
928 if !plain {
929 return None;
930 }
931 names.push(name);
932 }
933 let col = names.remove(0);
934 Some((col, names))
935 }
936
937 fn walk_all(&self, n: NodeId) -> Vec<NodeId> {
938 let mut out = vec![n];
939 let mut i = 0;
940 while i < out.len() {
941 out.extend(self.arbor.children(out[i]));
942 i += 1;
943 }
944 out
945 }
946
947 fn query(&mut self) -> Result<String, SqlError> {
948 let root = self.arbor.root();
949 let q = self
950 .kid(root, "query")
951 .ok_or_else(|| SqlError::Unsupported("empty query".into()))?;
952 let mut sel = Select::default();
953
954 // Correlations: exactly one becomes the JOIN's left table.
955 let corrs = self.kids(q, "query");
956 let branches = self.kids(q, "branch");
957 if branches.len() != 1 {
958 return Err(SqlError::Unsupported(
959 "a branch union (SQL has UNION, but result shapes differ; export one branch)"
960 .into(),
961 ));
962 }
963
964 if corrs.len() > 1 {
965 return Err(SqlError::Unsupported(
966 "more than one correlation context".into(),
967 ));
968 }
969 if let Some(corr) = corrs.first() {
970 // Driver-first: the query's own branch is the FROM
971 // table (the driver); the correlation entry is the
972 // joined table, whose `$$` equalities form the ON.
973 let (ltable, lpreds) = self.table_branch(branches[0])?;
974 let (rtable, rpreds) = self.table_branch(self.kids(*corr, "branch")[0])?;
975 // The SQL renderings of the two table names (validated
976 // or quoted); the raw names stay in the plan metadata,
977 // which the driver matches against its catalog.
978 let lsql = self.sql_ident(<able, "table name")?;
979 let rsql = self.sql_ident(&rtable, "table name")?;
980 self.from_sql = lsql.clone();
981 self.join_sql = Some(rsql.clone());
982 // The driver's own predicates are plain WHERE.
983 let driver_conds: Vec<NodeId> = lpreds;
984 // Split the joined expression's predicates: `$$`
985 // equalities form the ON, the rest the WHERE.
986 let mut on = Vec::new();
987 let mut wheres = Vec::new();
988 for p in rpreds {
989 self.split_join_pred(p, &rsql, &mut on, &mut wheres)?;
990 }
991 // `<=>?` — the outer correlation keeps a driver row no
992 // joined row admits, its witness null: a LEFT JOIN. The
993 // joined expression's own predicates then select the
994 // witness, so they belong to the ON; in a WHERE they
995 // would discard the unmatched rows again.
996 let outer = matches!(self.prop(*corr, "outer"), Some(Value::Bool(true)));
997 if outer {
998 on.append(&mut wheres);
999 }
1000 sel.join_outer = outer;
1001 if on.is_empty() {
1002 return Err(SqlError::Unsupported(
1003 "a correlation without a '$$' equality (no JOIN condition)".into(),
1004 ));
1005 }
1006 for p in driver_conds {
1007 let cond = self.predicate_cond(p, Some(&lsql.clone()))?;
1008 wheres.push(cond);
1009 }
1010 self.notes.push(
1011 "JOIN: Quarb's binding is existential (one row per FROM row, \
1012 bound to its first witness); SQL multiplies rows when \
1013 several joined rows match"
1014 .to_string(),
1015 );
1016 sel.from = lsql.clone();
1017 self.from_table = ltable;
1018 self.join_table = Some(rtable);
1019 sel.join = Some((rsql.clone(), on.join(" AND ")));
1020 sel.wheres = wheres;
1021 // A terminal projection on the driving branch is a
1022 // one-column select of the FROM table.
1023 if let Some(proj) = self.kid(branches[0], "projection") {
1024 let col = self.projection_col(proj)?;
1025 let col = self.sql_ident(&col, "column name")?;
1026 sel.select.push(format!("{lsql}.{col}"));
1027 }
1028 if self
1029 .kid(self.kids(*corr, "branch")[0], "projection")
1030 .is_some()
1031 {
1032 return Err(SqlError::Unsupported(
1033 "a projection on the joined expression (project the witness \
1034 in the pipeline: '$*1::col')"
1035 .into(),
1036 ));
1037 }
1038 self.pipeline(q, &mut sel, Some((&lsql, &rsql)))?;
1039 } else {
1040 let (table, preds) = self.table_branch(branches[0])?;
1041 sel.from = self.sql_ident(&table, "table name")?;
1042 self.from_table = table;
1043 for p in preds {
1044 let cond = self.predicate_cond(p, None)?;
1045 sel.wheres.push(cond);
1046 }
1047 // A terminal projection is a one-column select.
1048 if let Some(proj) = self.kid(branches[0], "projection") {
1049 let col = self.projection_col(proj)?;
1050 sel.select.push(self.sql_ident(&col, "column name")?);
1051 }
1052 self.pipeline(q, &mut sel, None)?;
1053 }
1054 Ok(sel.render())
1055 }
1056
1057 /// A `/table/*[preds]` branch: the table name and the row-step
1058 /// predicate nodes.
1059 fn table_branch(&mut self, b: NodeId) -> Result<(String, Vec<NodeId>), SqlError> {
1060 self.table_branch_lead(b, false)
1061 }
1062
1063 /// [`table_branch`]; with `more`, the branch may go on after
1064 /// `/table/*[…]` (a hop, a cell): the prefilter concerns its
1065 /// first two steps only, and the steps after them are held to
1066 /// the whole-query gates.
1067 fn table_branch_lead(
1068 &mut self,
1069 b: NodeId,
1070 more: bool,
1071 ) -> Result<(String, Vec<NodeId>), SqlError> {
1072 let steps = self.kids(b, "step");
1073 if steps.len() < 2 || (steps.len() > 2 && !more) {
1074 return Err(SqlError::Unsupported(
1075 "navigation beyond /table/* (SQL sees tables and rows)".into(),
1076 ));
1077 }
1078 let (t, rows) = (steps[0], steps[1]);
1079 if self.prop_s(t, "axis") != "/"
1080 || self.prop_s(t, "matcher-kind") != "name"
1081 || self.prop_s(rows, "axis") != "/"
1082 || self.prop_s(rows, "matcher-kind") != "any"
1083 {
1084 return Err(SqlError::Unsupported(
1085 "navigation beyond /table/* (SQL sees tables and rows)".into(),
1086 ));
1087 }
1088 if self.kid(t, "predicate").is_some() {
1089 return Err(SqlError::Unsupported("a predicate on the table hop".into()));
1090 }
1091 Ok((self.prop_s(t, "matcher"), self.kids(rows, "predicate")))
1092 }
1093
1094 /// One row predicate as a WHERE condition (qualify columns with
1095 /// `qualifier` when joining).
1096 fn predicate_cond(&mut self, p: NodeId, qual: Option<&str>) -> Result<String, SqlError> {
1097 if self.prop_s(p, "kind") != "expr" {
1098 return Err(SqlError::Unsupported(
1099 "a positional predicate on rows (SQL rows are unordered; ORDER BY + LIMIT)".into(),
1100 ));
1101 }
1102 let parts: Vec<String> = self
1103 .arbor
1104 .children(p)
1105 .into_iter()
1106 .map(|c| self.pred_expr(c, qual))
1107 .collect::<Result<_, _>>()?;
1108 Ok(parts.join(" AND "))
1109 }
1110
1111 fn pred_expr(&mut self, e: NodeId, qual: Option<&str>) -> Result<String, SqlError> {
1112 match self.kind(e).as_str() {
1113 "and" | "or" => {
1114 let op = self.kind(e).to_uppercase();
1115 let kids: Vec<String> = self
1116 .arbor
1117 .children(e)
1118 .into_iter()
1119 .map(|c| self.pred_expr(c, qual))
1120 .collect::<Result<_, _>>()?;
1121 Ok(format!("({})", kids.join(&format!(" {op} "))))
1122 }
1123 "not" => {
1124 // SQL's `NOT` propagates UNKNOWN: `NOT (x = 5)` is
1125 // UNKNOWN for a NULL `x` and drops the row, but Quarb's
1126 // negation keeps it (the inner `value_eq` is false, so
1127 // its negation is true). Not provably identical without
1128 // the schema — the pushdown paths refuse it and scan.
1129 if self.strict {
1130 return Err(SqlError::Semantics(
1131 "SQL NOT propagates UNKNOWN, dropping the NULL rows \
1132 Quarb keeps"
1133 .into(),
1134 ));
1135 }
1136 let inner: Vec<String> = self
1137 .arbor
1138 .children(e)
1139 .into_iter()
1140 .map(|c| self.pred_expr(c, qual))
1141 .collect::<Result<_, _>>()?;
1142 Ok(format!("NOT ({})", inner.join(" AND ")))
1143 }
1144 "parens" => {
1145 let inner: Vec<String> = self
1146 .arbor
1147 .children(e)
1148 .into_iter()
1149 .map(|c| self.pred_expr(c, qual))
1150 .collect::<Result<_, _>>()?;
1151 Ok(format!("({})", inner.join(" AND ")))
1152 }
1153 "compare" => {
1154 let op = self.prop_s(e, "op");
1155 let kids = self.arbor.children(e);
1156 // A comparison against a bare `null` literal. Quarb's
1157 // `value_eq` treats NULL as an ordinary value
1158 // (`value_eq(NULL, NULL)` is true, `value_eq(NULL, x)`
1159 // false), so `= null` keeps exactly the NULL rows and
1160 // `!= null` the non-NULL rows. SQL's `= NULL` / `<>
1161 // NULL` are always UNKNOWN and drop every row; the
1162 // `IS [NOT] NULL` forms are provably identical (and
1163 // portable across every target dialect).
1164 if matches!(op.as_str(), "=" | "!=")
1165 && (self.is_null_literal(kids[0]) || self.is_null_literal(kids[1]))
1166 {
1167 let other = if self.is_null_literal(kids[0]) {
1168 kids[1]
1169 } else {
1170 kids[0]
1171 };
1172 let col = self.operand(other, qual)?;
1173 return Ok(if op == "=" {
1174 format!("{col} IS NULL")
1175 } else {
1176 format!("{col} IS NOT NULL")
1177 });
1178 }
1179 // JSON-column-path pushdown: `[/col/a/b:: = 'lit']`
1180 // navigates into a JSON column and compares a fixed
1181 // path to a string literal. Only this exact shape —
1182 // fixed object path, string equality, and a literal
1183 // that does NOT look numeric — is provably identical
1184 // to the client-side graft (each engine's scalar
1185 // extractor unquotes to text, and an absent path or
1186 // non-string value excludes the row on both sides,
1187 // matching Quarb's `value_eq` on non-numeric text).
1188 // Numeric casts, `!=`, wildcards, and deeper
1189 // predicates fall through to the ordinary operand
1190 // logic below, which refuses the navigation; the
1191 // partial-pushdown prefilter ladder picks those up
1192 // with superset semantics. Enabled only when a
1193 // dialect is set.
1194 if op == "="
1195 && let Some(dialect) = self.dialect
1196 {
1197 for (pi, li) in [(0usize, 1usize), (1, 0)] {
1198 if self.is_text_literal(kids[li])
1199 && let Some((col, path)) = self.json_path(kids[pi])
1200 {
1201 self.note_json_column(kids[pi], &col);
1202 // Quarb's value_eq compares numeric-looking
1203 // strings numerically — '150' matches the
1204 // JSON number 150, and the string "150.0".
1205 // No engine's text extractor coerces that
1206 // way, so the pushed compare would drop
1207 // rows the graft keeps. Refuse; the
1208 // prefilter ladder handles it as a
1209 // superset.
1210 let raw = self
1211 .prop(kids[li], "value")
1212 .map(|v| v.to_string())
1213 .unwrap_or_default();
1214 if raw.trim().parse::<f64>().is_ok() {
1215 return Err(SqlError::Semantics(
1216 "a numeric-looking text literal against a \
1217 JSON path compares numerically in Quarb \
1218 (value coercion); no SQL extractor \
1219 matches that"
1220 .into(),
1221 ));
1222 }
1223 let lit = self.operand(kids[li], qual)?;
1224 let extract = json_extract(dialect, qual, &col, &path);
1225 return Ok(json_valid_guard(
1226 dialect,
1227 qual,
1228 &col,
1229 format!("{extract} = {lit}"),
1230 ));
1231 }
1232 }
1233 }
1234 // Any other JSON-path comparison: name the real
1235 // reason, not the generic flat-rows refusal — only
1236 // fixed-path string equality is provably identical
1237 // (Quarb coerces numeric-looking values; no
1238 // engine's extractor matches that). The partial
1239 // prefilter ladder covers these shapes.
1240 if self.dialect.is_some() {
1241 for side in [kids[0], kids[1]] {
1242 if self.json_path(side).is_some() || self.json_path_loose(side).is_some() {
1243 return Err(SqlError::Semantics(
1244 "a JSON-path comparison pushes whole only as \
1245 fixed-path string equality (Quarb coerces \
1246 numeric-looking values; no extractor matches \
1247 that); this shape rides the partial prefilter"
1248 .into(),
1249 ));
1250 }
1251 }
1252 }
1253 // Quarb compares by value; SQL by the column's type.
1254 // A column against a numeric literal is the scan's
1255 // comparison only on a numeric-typed column, and
1256 // against a text literal only on a column that is
1257 // not (MySQL reads `n = 'abc'` as `n = 0`): the
1258 // catalog decides, so both are recorded for the
1259 // caller. A numeric-looking TEXT literal meets
1260 // numbers as a number in Quarb and compares as text
1261 // in SQL, on every engine, so it does not push.
1262 if self.strict {
1263 for (ci, li) in [(0usize, 1usize), (1, 0)] {
1264 let Some(column) = self.column_operand(kids[ci]) else {
1265 continue;
1266 };
1267 if self.kind(kids[li]) != "literal" {
1268 continue;
1269 }
1270 let numeric = self
1271 .prop(kids[li], "value")
1272 .is_some_and(|v| Value::Str(v.to_string()).numeric_reading().is_some());
1273 let list = match (self.prop_s(kids[li], "type").as_str(), numeric) {
1274 ("null", _) => continue,
1275 ("text", true) => {
1276 return Err(SqlError::Semantics(
1277 "a numeric-looking text literal compares \
1278 numerically in Quarb (value coercion); SQL \
1279 compares it as text"
1280 .into(),
1281 ));
1282 }
1283 ("text", false) => &mut self.column_types.text,
1284 (_, true) => &mut self.column_types.numeric,
1285 (_, false) => continue,
1286 };
1287 if !list.contains(&column) {
1288 list.push(column);
1289 }
1290 }
1291 }
1292 let l = self.operand(kids[0], qual)?;
1293 // Ruling #33: a pattern literal on `=` pushes as
1294 // LIKE — a `%` per star, each literal segment
1295 // escaped, in order. Anchoring matches exactly:
1296 // LIKE anchors both ends, a flanking star lifts
1297 // one. The case-folding caveat is `*=`'s; `!=`
1298 // with a pattern rides the scan (NOT LIKE drops
1299 // the NULL rows Quarb keeps).
1300 if self.kind(kids[1]) == "pattern" {
1301 if op.as_str() != "=" {
1302 return Err(SqlError::Unsupported(format!(
1303 "a pattern literal on '{op}' (only '=' pushes)"
1304 )));
1305 }
1306 // PostgreSQL's LIKE is case-sensitive under its
1307 // deterministic collations — provably what the
1308 // pattern test does — so the strict push is
1309 // sound there (and a prefix pattern is
1310 // index-sargable server-side). Everywhere else
1311 // case folding is ambient state (SQLite pragma,
1312 // MySQL/MSSQL collation), so strict refuses.
1313 if self.strict && self.dialect != Some(Dialect::Postgres) {
1314 return Err(SqlError::Semantics(
1315 "LIKE case folding differs per engine \
1316 (provably case-sensitive only on PostgreSQL)"
1317 .into(),
1318 ));
1319 }
1320 if !self.strict {
1321 self.notes.push(
1322 "pattern → LIKE: Quarb's pattern test is \
1323 case-sensitive; LIKE folds case on \
1324 SQLite/MySQL but not PostgreSQL"
1325 .to_string(),
1326 );
1327 }
1328 let mut like = String::new();
1329 for kid in self.arbor.children(kids[1]) {
1330 match self.kind(kid).as_str() {
1331 "star" => like.push('%'),
1332 _ => {
1333 let raw = self
1334 .prop(kid, "value")
1335 .map(|v| v.to_string())
1336 .unwrap_or_default();
1337 like.push_str(
1338 &raw.replace('\\', "\\\\")
1339 .replace('%', "\\%")
1340 .replace('_', "\\_")
1341 .replace('\'', "''"),
1342 );
1343 }
1344 }
1345 }
1346 return Ok(format!("{l} LIKE '{like}' ESCAPE '\\'"));
1347 }
1348 let r = self.operand(kids[1], qual)?;
1349 Ok(match op.as_str() {
1350 "=" => format!("{l} = {r}"),
1351 "!=" => {
1352 // Quarb keeps rows whose operand is NULL (its
1353 // `value_eq` is false there, so `!=` is true);
1354 // SQL's `<>` is UNKNOWN for a NULL operand and
1355 // drops those rows. Not provably identical
1356 // without the schema, so pushdown refuses it;
1357 // the display translation keeps `<>` and notes
1358 // the divergence.
1359 if self.strict {
1360 return Err(SqlError::Semantics(
1361 "'!=' drops the NULL rows Quarb keeps \
1362 (SQL '<>' is UNKNOWN for NULL; use '!= null' \
1363 for IS NOT NULL)"
1364 .into(),
1365 ));
1366 }
1367 self.notes.push(
1368 "'!=' → '<>': Quarb keeps rows whose column is NULL; \
1369 SQL's '<>' drops them (use '!= null' for IS NOT NULL)"
1370 .to_string(),
1371 );
1372 format!("{l} <> {r}")
1373 }
1374 "<" | "<=" | ">" | ">=" => format!("{l} {op} {r}"),
1375 "*=" => {
1376 // The same PostgreSQL rung as the pattern
1377 // literal above: LIKE is provably
1378 // case-sensitive there, so `*=` pushes
1379 // strict too.
1380 if self.strict && self.dialect != Some(Dialect::Postgres) {
1381 return Err(SqlError::Semantics(
1382 "LIKE case folding differs per engine \
1383 (provably case-sensitive only on PostgreSQL)"
1384 .into(),
1385 ));
1386 }
1387 // The pattern must be a text literal: a
1388 // column or computed operand holds a value,
1389 // not a pattern, and SQL LIKE cannot express
1390 // "contains that value" portably.
1391 if !self.is_text_literal(kids[1]) {
1392 return Err(SqlError::Unsupported(
1393 "'*=' with a non-literal pattern".into(),
1394 ));
1395 }
1396 self.notes.push(
1397 "*= → LIKE: Quarb's substring test is case-sensitive; LIKE \
1398 folds case on SQLite/MySQL but not PostgreSQL"
1399 .to_string(),
1400 );
1401 // Escape LIKE's metacharacters, then quote.
1402 // The explicit ESCAPE makes '\' the escape
1403 // everywhere — SQLite, MSSQL, and Oracle
1404 // have no default escape character.
1405 let raw = self
1406 .prop(kids[1], "value")
1407 .map(|v| v.to_string())
1408 .unwrap_or_default();
1409 let pat = raw
1410 .replace('\\', "\\\\")
1411 .replace('%', "\\%")
1412 .replace('_', "\\_")
1413 .replace('\'', "''");
1414 format!("{l} LIKE '%{pat}%' ESCAPE '\\'")
1415 }
1416 "==" | "!==" | "=~" | "!~" => {
1417 return Err(SqlError::Semantics(
1418 "regex matching means a different REGEXP engine per \
1419 backend; regexes run engine-side"
1420 .into(),
1421 ));
1422 }
1423 other => {
1424 return Err(SqlError::Unsupported(format!("the '{other}' comparison")));
1425 }
1426 })
1427 }
1428 // A bare truthy operand.
1429 _ => {
1430 if self.strict {
1431 return Err(SqlError::Semantics(
1432 "truthiness diverges (0 and '' are falsy in Quarb)".into(),
1433 ));
1434 }
1435 self.notes.push(
1436 "truthiness: '[::c]' exports as IS NOT NULL, but Quarb also treats \
1437 0 and '' as falsy"
1438 .to_string(),
1439 );
1440 Ok(format!("{} IS NOT NULL", self.operand(e, qual)?))
1441 }
1442 }
1443 }
1444
1445 fn operand(&mut self, o: NodeId, qual: Option<&str>) -> Result<String, SqlError> {
1446 match self.kind(o).as_str() {
1447 "literal" => {
1448 let v = self.prop(o, "value").unwrap_or(Value::Null);
1449 match self.prop_s(o, "type").as_str() {
1450 "text" => {
1451 let s = v.to_string();
1452 // No single escaping is portable across the
1453 // pushdown's target dialects: MySQL (default
1454 // sql_mode) and BigQuery read `\` as an escape
1455 // while SQLite/PostgreSQL/DuckDB take it
1456 // literally, and BigQuery rejects the `''`
1457 // quote-doubling the others require. A literal
1458 // carrying either character cannot be pushed as
1459 // provably identical SQL, so refuse it and let
1460 // the caller scan. (The display translation
1461 // keeps its best-effort `''`-doubling.)
1462 if self.strict && (s.contains('\'') || s.contains('\\')) {
1463 return Err(SqlError::Semantics(
1464 "a text literal with a quote or backslash \
1465 has no escaping portable across SQL dialects"
1466 .into(),
1467 ));
1468 }
1469 Ok(format!("'{}'", s.replace('\'', "''")))
1470 }
1471 "null" => Ok("NULL".to_string()),
1472 _ => Ok(v.to_string()),
1473 }
1474 }
1475 "path" => {
1476 if self.kid(o, "step").is_some() {
1477 // A step here is either navigation (refused) or
1478 // a resolution chain (refused with the reason).
1479 let s = self.kids(o, "step")[0];
1480 if self.prop_s(s, "axis") == "-->" {
1481 return Err(SqlError::Unsupported(
1482 "a '-->' resolution chain: the foreign-key targets live in \
1483 the schema, not the query — spell the join with '<=>' to \
1484 export it"
1485 .into(),
1486 ));
1487 }
1488 return Err(SqlError::Unsupported(
1489 "navigation inside a predicate (SQL rows are flat)".into(),
1490 ));
1491 }
1492 let p = self
1493 .kid(o, "projection")
1494 .ok_or_else(|| SqlError::Unsupported("an empty path operand".into()))?;
1495 let col = self.projection_col(p)?;
1496 let col = self.sql_ident(&col, "column name")?;
1497 Ok(match qual {
1498 Some(q) => format!("{q}.{col}"),
1499 None => col,
1500 })
1501 }
1502 "context" => {
1503 // `$*1::col` — the joined expression's witness, in
1504 // pipeline position: the joined table's column.
1505 // Anything else is outside the verified-safe set.
1506 let p = self
1507 .kid(o, "projection")
1508 .ok_or_else(|| SqlError::Unsupported("a bare '$*' reference".into()))?;
1509 let col = self.projection_col(p)?;
1510 let col = self.sql_ident(&col, "column name")?;
1511 if matches!(self.prop(o, "index"), Some(Value::Int(0))) {
1512 // `$$0::col` — the driver, legal only inside the
1513 // joined expression's ON bracket (the parser
1514 // refuses it anywhere else).
1515 if !self.in_on {
1516 return Err(SqlError::Unsupported(
1517 "a '$$0' reference outside the join's ON".into(),
1518 ));
1519 }
1520 if self.kid(o, "step").is_some() {
1521 return Err(SqlError::Unsupported(
1522 "a '$$0' reference beyond a plain column ($$0::col)".into(),
1523 ));
1524 }
1525 return Ok(format!("{}.{col}", self.from_sql.clone()));
1526 }
1527 if self.in_on {
1528 return Err(SqlError::Unsupported(
1529 "a '$*' reference inside the ON (the driver is '$$0')".into(),
1530 ));
1531 }
1532 let Some(join) = self.join_sql.clone() else {
1533 return Err(SqlError::Unsupported(
1534 "a '$*' reference outside a correlation join".into(),
1535 ));
1536 };
1537 match self.prop(o, "index") {
1538 Some(Value::Int(1)) => Ok(format!("{join}.{col}")),
1539 None => Err(SqlError::Unsupported("a bare '$*' reference".into())),
1540 Some(v) => Err(SqlError::Unsupported(format!(
1541 "pushdown: $*{v} beyond a two-branch correlation"
1542 ))),
1543 }
1544 }
1545 "invoking" => {
1546 // `$$__::col` — the invoking position of an enclosing
1547 // query body, never the driver (that is `$$0`, a
1548 // context of index 0, since draft 153). SQL has no
1549 // enclosing body to read.
1550 Err(SqlError::Unsupported(
1551 "a '$$__' reference (the driver inside the ON is '$$0')".into(),
1552 ))
1553 }
1554 "arith" => {
1555 let op = self.prop_s(o, "op");
1556 let kids = self.arbor.children(o);
1557 let l = self.operand(kids[0], qual)?;
1558 let r = self.operand(kids[1], qual)?;
1559 Ok(match op.as_str() {
1560 "+" | "-" | "*" => format!("({l} {op} {r})"),
1561 "div" => format!("({l} / {r})"),
1562 "mod" => format!("({l} % {r})"),
1563 other => return Err(SqlError::Unsupported(format!("'{other}' arithmetic"))),
1564 })
1565 }
1566 other => Err(SqlError::Unsupported(format!(
1567 "the '{other}' operand (registers, topics, and captures are Quarb-side state)"
1568 ))),
1569 }
1570 }
1571
1572 fn projection_col(&mut self, p: NodeId) -> Result<String, SqlError> {
1573 match self.prop_s(p, "kind").as_str() {
1574 "property" => match self.prop(p, "key") {
1575 Some(k) => Ok(k.to_string()),
1576 None => Err(SqlError::Unsupported(
1577 "the bare '::' projection (name the column)".into(),
1578 )),
1579 },
1580 other => Err(SqlError::Unsupported(format!(
1581 "the {other} metadata projection"
1582 ))),
1583 }
1584 }
1585
1586 /// The column a plain column operand names (`::col`, `$*1::col`):
1587 /// a path without a step, or a context reference, projecting a
1588 /// named property.
1589 fn column_operand(&self, o: NodeId) -> Option<String> {
1590 match self.kind(o).as_str() {
1591 "path" if self.kid(o, "step").is_none() => {}
1592 "context" => {}
1593 _ => return None,
1594 }
1595 let p = self.kid(o, "projection")?;
1596 if self.prop_s(p, "kind") != "property" {
1597 return None;
1598 }
1599 self.prop(p, "key").map(|k| k.to_string())
1600 }
1601
1602 /// Whether `o` is a text string literal (`'London'`).
1603 fn is_text_literal(&self, o: NodeId) -> bool {
1604 self.kind(o) == "literal" && self.prop_s(o, "type") == "text"
1605 }
1606
1607 /// If `o` is the one JSON-column-path shape pushdown handles —
1608 /// `/col/seg/seg…::`, a plain-navigation path (every hop `/`
1609 /// and a plain-identifier object key, no wildcards, no nested
1610 /// predicate), at least one segment past the column, ending in
1611 /// the bare `::` projection — return `(column, [json segments])`.
1612 /// Anything else is `None` (and falls back to the graft).
1613 fn json_path(&self, o: NodeId) -> Option<(String, Vec<String>)> {
1614 if self.kind(o) != "path" {
1615 return None;
1616 }
1617 let steps = self.kids(o, "step");
1618 if steps.len() < 2 {
1619 return None;
1620 }
1621 // The projection must be the bare `::` (default value of
1622 // the JSON leaf), not `::key` or a `:::`/`:` metadata form.
1623 let proj = self.kid(o, "projection")?;
1624 if self.prop_s(proj, "kind") != "property" || self.prop(proj, "key").is_some() {
1625 return None;
1626 }
1627 let mut names = Vec::with_capacity(steps.len());
1628 for s in &steps {
1629 if self.prop_s(*s, "axis") != "/"
1630 || self.prop_s(*s, "matcher-kind") != "name"
1631 || self.kid(*s, "predicate").is_some()
1632 {
1633 return None;
1634 }
1635 let name = self.prop_s(*s, "matcher");
1636 // Plain object keys only — no array indices, no
1637 // characters that would need per-dialect path escaping.
1638 let plain = !name.is_empty()
1639 && name
1640 .chars()
1641 .next()
1642 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
1643 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
1644 if !plain {
1645 return None;
1646 }
1647 names.push(name);
1648 }
1649 let col = names.remove(0);
1650 Some((col, names))
1651 }
1652
1653 /// A safe `AS` alias: bare when it is a plain identifier and
1654 /// not an SQL keyword; otherwise strict mode refuses (quoting
1655 /// dialects disagree) and export mode double-quotes, noting it.
1656 fn alias(&mut self, name: &str) -> Result<String, SqlError> {
1657 if is_plain_ident(name) {
1658 return Ok(name.to_string());
1659 }
1660 if self.strict {
1661 return Err(SqlError::Unsupported(format!(
1662 "pushdown: field name {name:?} needs SQL quoting \
1663 (dialects disagree); rename the field"
1664 )));
1665 }
1666 self.notes
1667 .push(format!("field name {name:?} double-quoted (ANSI)"));
1668 Ok(format!("\"{}\"", name.replace('"', "\"\"")))
1669 }
1670
1671 /// A table or column name rendered into SQL. Bare when it is a
1672 /// plain identifier; otherwise strict mode refuses — a name
1673 /// like `a OR b` would silently rewrite the emitted SQL's
1674 /// meaning, breaking the provably-identical guarantee (and a
1675 /// portable quoting does not exist) — and export mode
1676 /// double-quotes with a note.
1677 fn sql_ident(&mut self, name: &str, what: &str) -> Result<String, SqlError> {
1678 if is_plain_ident(name) {
1679 return Ok(name.to_string());
1680 }
1681 if self.strict {
1682 return Err(SqlError::Unsupported(format!(
1683 "pushdown: {what} {name:?} is not a plain SQL identifier"
1684 )));
1685 }
1686 self.notes
1687 .push(format!("{what} {name:?} double-quoted (ANSI)"));
1688 Ok(format!("\"{}\"", name.replace('"', "\"\"")))
1689 }
1690
1691 /// Split a joined-side predicate: `col = $*1::col2` equalities
1692 /// become the ON condition; everything else the WHERE.
1693 /// `ltable` and `rtable` are the tables' SQL renderings.
1694 fn split_join_pred(
1695 &mut self,
1696 p: NodeId,
1697 rtable: &str,
1698 on: &mut Vec<String>,
1699 wheres: &mut Vec<String>,
1700 ) -> Result<(), SqlError> {
1701 if self.prop_s(p, "kind") != "expr" {
1702 return Err(SqlError::Unsupported(
1703 "a positional predicate on the joined side".into(),
1704 ));
1705 }
1706 // Flatten top-level ANDs; each conjunct routes to ON or
1707 // WHERE.
1708 fn conjuncts(ex: &Exporter, e: NodeId, out: &mut Vec<NodeId>) {
1709 if ex.kind(e) == "and" {
1710 for c in ex.arbor.children(e) {
1711 conjuncts(ex, c, out);
1712 }
1713 } else {
1714 out.push(e);
1715 }
1716 }
1717 let mut parts = Vec::new();
1718 for c in self.arbor.children(p) {
1719 conjuncts(self, c, &mut parts);
1720 }
1721 for e in parts {
1722 let uses_driver = self.subtree_has_driver(e);
1723 self.in_on = true;
1724 let cond = self.pred_expr(e, Some(rtable));
1725 self.in_on = false;
1726 let cond = cond?;
1727 if uses_driver && self.kind(e) == "compare" && self.prop_s(e, "op") == "=" {
1728 // Record which joined-table columns the ON binds —
1729 // the uniqueness obligation (see
1730 // Pushdown::join_left). Collected from the arbor's
1731 // bare-column operand nodes, never from the
1732 // rendered SQL text, so neither a literal nor an
1733 // unusual column name can corrupt the obligation.
1734 self.collect_join_cols(e)?;
1735 on.push(cond);
1736 } else {
1737 wheres.push(cond);
1738 }
1739 }
1740 Ok(())
1741 }
1742
1743 /// The joined-table columns the ON equality binds: every bare
1744 /// column operand (`::col`) in `e`'s subtree, appended to the
1745 /// join obligation with their raw (catalog) names.
1746 fn collect_join_cols(&mut self, e: NodeId) -> Result<(), SqlError> {
1747 // The `$$0` side is the driver — not part of the joined
1748 // table's obligation.
1749 if self.is_driver(e) {
1750 return Ok(());
1751 }
1752 if self.kind(e) == "path"
1753 && self.kid(e, "step").is_none()
1754 && let Some(p) = self.kid(e, "projection")
1755 {
1756 let col = self.projection_col(p)?;
1757 self.join_on_cols.push(col);
1758 }
1759 for c in self.arbor.children(e) {
1760 self.collect_join_cols(c)?;
1761 }
1762 Ok(())
1763 }
1764
1765 /// `$$0`: a context operand of index 0 (the driver).
1766 fn is_driver(&self, n: NodeId) -> bool {
1767 self.kind(n) == "context" && matches!(self.prop(n, "index"), Some(Value::Int(0)))
1768 }
1769
1770 fn subtree_has_driver(&self, n: NodeId) -> bool {
1771 self.is_driver(n)
1772 || self
1773 .arbor
1774 .children(n)
1775 .into_iter()
1776 .any(|c| self.subtree_has_driver(c))
1777 }
1778
1779 /// Consume the pipeline into SELECT clauses.
1780 fn pipeline(
1781 &mut self,
1782 q: NodeId,
1783 sel: &mut Select,
1784 join: Option<(&str, &str)>,
1785 ) -> Result<(), SqlError> {
1786 let Some(pipe) = self.kid(q, "pipeline") else {
1787 return Ok(());
1788 };
1789 let commands: Vec<NodeId> = self.arbor.children(pipe);
1790 let mut i = 0;
1791 // A pending per-row value (`| ::col`) feeding an aggregate.
1792 let mut pending_col: Option<String> = None;
1793 // After a grouped reduction: (key, agg_sql, alias).
1794 let mut grouped: Option<(String, String, String)> = None;
1795
1796 while i < commands.len() {
1797 let s = commands[i];
1798 match self.kind(s).as_str() {
1799 "expr" => {
1800 let kids = self.arbor.children(s);
1801 pending_col = Some(self.operand(kids[0], join.map(|(l, _)| l))?);
1802 }
1803 "cmd" => {
1804 let name = self.prop_s(s, "name");
1805 match name.as_str() {
1806 "rec" | "record" => {
1807 sel.select = self.record_fields(s, join)?;
1808 }
1809 // A reducing aggregate on the plain pipe:
1810 // the grouped reduction.
1811 "count" | "sum" | "mean" | "avg" | "min" | "max" => {
1812 let (key, _, _) = grouped.as_ref().ok_or_else(|| {
1813 SqlError::Unsupported(format!(
1814 "'| {name}' outside a group (use '@| {name}')"
1815 ))
1816 })?;
1817 // Quarb's count counts every member,
1818 // nulls included: COUNT(*), never the
1819 // NULL-skipping COUNT(col).
1820 let col = if name == "count" {
1821 pending_col = None;
1822 None
1823 } else {
1824 pending_col.take()
1825 };
1826 let agg = sql_agg(&name, col)?;
1827 grouped = Some((key.clone(), agg.clone(), agg));
1828 }
1829 other => {
1830 // sort/unique/top and kin are spellable
1831 // in SQL but with the backend's own
1832 // collation and duplicate semantics —
1833 // name the divergence, not a missing
1834 // feature.
1835 if matches!(other, "sort" | "sort_by" | "unique" | "top" | "bottom") {
1836 return Err(SqlError::Semantics(format!(
1837 "'{other}' means the backend's collation and \
1838 duplicate semantics; one engine-side ordering \
1839 keeps it identical everywhere"
1840 )));
1841 }
1842 return Err(SqlError::Unsupported(format!(
1843 "the '{other}' pipeline command"
1844 )));
1845 }
1846 }
1847 }
1848 "push" => {
1849 // An alias for the grouped aggregate.
1850 if let Some((k, agg, _)) = grouped.take() {
1851 let alias = self.prop_s(s, "name");
1852 grouped = Some((k, agg, alias));
1853 } else {
1854 return Err(SqlError::Unsupported(
1855 "a register push (Quarb-side state)".into(),
1856 ));
1857 }
1858 }
1859 "filter" => {
1860 if grouped.is_some() {
1861 // HAVING: `$_` / the alias refer to the
1862 // aggregate.
1863 let cond = self.having_cond(s)?;
1864 sel.having = Some(cond);
1865 } else {
1866 return Err(SqlError::Unsupported(
1867 "a mid-pipeline filter (put it in the row predicate)".into(),
1868 ));
1869 }
1870 }
1871 "recall" => {
1872 // `| %.` finalizes the grouped record.
1873 if self.prop_s(s, "ref") != "%." {
1874 return Err(SqlError::Unsupported(
1875 "a register recall (Quarb-side state)".into(),
1876 ));
1877 }
1878 let (key, agg, alias) = grouped.clone().ok_or_else(|| {
1879 SqlError::Unsupported("'%.', with nothing grouped".into())
1880 })?;
1881 let alias = self.alias(&alias)?;
1882 sel.select = vec![key.clone(), format!("{agg} AS {alias}")];
1883 // The HAVING condition compared the aggregate
1884 // through $_ — substitute the real expression.
1885 if let Some(h) = sel.having.take() {
1886 sel.having = Some(h.replace("__AGG__", &agg));
1887 }
1888 }
1889 "agg" => {
1890 let name = self.prop_s(s, "name");
1891 match name.as_str() {
1892 "count" | "sum" | "mean" | "avg" | "min" | "max" => {
1893 // Quarb's count counts every row, nulls
1894 // included: COUNT(*) regardless of a
1895 // pending column.
1896 let col = if name == "count" {
1897 pending_col = None;
1898 None
1899 } else {
1900 pending_col.take()
1901 };
1902 sel.select = vec![sql_agg(&name, col)?];
1903 self.aggregate = true;
1904 }
1905 "group" => {
1906 if self.strict {
1907 return Err(SqlError::Semantics(
1908 "GROUP BY result order is unordered in SQL".into(),
1909 ));
1910 }
1911 self.notes.push(
1912 "GROUP BY: SQL keeps a NULL-key group; Quarb's group \
1913 drops null keys"
1914 .to_string(),
1915 );
1916 let key = self.group_key(s, join)?;
1917 sel.group_by = Some(key.clone());
1918 grouped = Some((key, String::new(), String::new()));
1919 }
1920 "sort_by" => {
1921 if self.strict {
1922 return Err(SqlError::Semantics(
1923 "ORDER BY collations differ per engine".into(),
1924 ));
1925 }
1926 // A sort after a positional selection
1927 // (or a second sort) cannot render: the
1928 // fixed SELECT shape orders before
1929 // LIMIT, and has one ORDER BY.
1930 if sel.limit.is_some() {
1931 return Err(SqlError::Unsupported(
1932 "a sort after a positional selection (SQL orders \
1933 before LIMIT)"
1934 .into(),
1935 ));
1936 }
1937 if sel.order_by.is_some() {
1938 return Err(SqlError::Unsupported(
1939 "a second sort (SQL has a single ORDER BY)".into(),
1940 ));
1941 }
1942 let kids = self.arbor.children(s);
1943 let e = self.operand(kids[0], join.map(|(l, _)| l))?;
1944 sel.order_by = Some((e, false));
1945 }
1946 "reverse" => match &mut sel.order_by {
1947 Some((_, desc)) => *desc = true,
1948 None => {
1949 return Err(SqlError::Unsupported(
1950 "reverse without an ORDER BY (rows are unordered)".into(),
1951 ));
1952 }
1953 },
1954 "top" => {
1955 if self.strict {
1956 return Err(SqlError::Semantics(
1957 "ORDER BY collations differ per engine".into(),
1958 ));
1959 }
1960 if sel.limit.is_some() {
1961 return Err(SqlError::Unsupported(
1962 "'top' after a positional selection (SQL orders \
1963 before LIMIT)"
1964 .into(),
1965 ));
1966 }
1967 if sel.order_by.is_some() {
1968 return Err(SqlError::Unsupported(
1969 "a second sort (SQL has a single ORDER BY)".into(),
1970 ));
1971 }
1972 let kids = self.arbor.children(s);
1973 let n = self.prop_s(kids[0], "value");
1974 let e = self.operand(kids[1], join.map(|(l, _)| l))?;
1975 sel.order_by = Some((e, true));
1976 sel.limit = Some(n);
1977 }
1978 "unique" => {
1979 if self.strict {
1980 return Err(SqlError::Unsupported(
1981 "pushdown: DISTINCT result order is unordered in SQL".into(),
1982 ));
1983 }
1984 // Quarb dedups the limited rows; SQL
1985 // applies DISTINCT before LIMIT.
1986 if sel.limit.is_some() {
1987 return Err(SqlError::Unsupported(
1988 "'unique' after a positional selection (SQL applies \
1989 DISTINCT before LIMIT)"
1990 .into(),
1991 ));
1992 }
1993 if let Some(c) = pending_col.take() {
1994 sel.select = vec![c];
1995 }
1996 sel.distinct = true;
1997 }
1998 other => {
1999 return Err(SqlError::Unsupported(format!("the '{other}' aggregate")));
2000 }
2001 }
2002 }
2003 "select" => {
2004 if self.strict {
2005 return Err(SqlError::Unsupported(
2006 "pushdown: LIMIT without a guaranteed order".into(),
2007 ));
2008 }
2009 // `@| [..n]` → LIMIT.
2010 if sel.limit.is_some() {
2011 return Err(SqlError::Unsupported(
2012 "a second positional selection".into(),
2013 ));
2014 }
2015 let p = self.arbor.children(s)[0];
2016 match (self.prop_s(p, "kind").as_str(), self.prop(p, "to")) {
2017 ("range", Some(Value::Int(n)))
2018 if self.prop(p, "from").is_none() && n > 0 =>
2019 {
2020 sel.limit = Some(n.to_string());
2021 }
2022 _ => {
2023 return Err(SqlError::Unsupported(
2024 "positional selection beyond '@| [..n]'".into(),
2025 ));
2026 }
2027 }
2028 }
2029 other => {
2030 return Err(SqlError::Unsupported(format!(
2031 "the '{other}' command (windows, subcontexts, and registers are \
2032 Quarb-side state)"
2033 )));
2034 }
2035 }
2036 i += 1;
2037 }
2038 // A pending column with no aggregate is a one-column select.
2039 if let Some(c) = pending_col
2040 && sel.select.is_empty()
2041 {
2042 sel.select = vec![c];
2043 }
2044 Ok(())
2045 }
2046
2047 fn group_key(&mut self, s: NodeId, join: Option<(&str, &str)>) -> Result<String, SqlError> {
2048 let kids = self.arbor.children(s);
2049 // group(::k) or group("name", expr) — the key expression is
2050 // the last child; a literal first child is its name.
2051 let key = kids
2052 .iter()
2053 .rev()
2054 .find(|&&k| self.kind(k) != "literal")
2055 .ok_or_else(|| SqlError::Unsupported("a literal group key".into()))?;
2056 self.operand(*key, join.map(|(l, _)| l))
2057 }
2058
2059 /// A HAVING filter: `$_` and `$.name` refer to the aggregate.
2060 fn having_cond(&mut self, s: NodeId) -> Result<String, SqlError> {
2061 let kids = self.arbor.children(s);
2062 if kids.len() != 1 || self.kind(kids[0]) != "compare" {
2063 return Err(SqlError::Unsupported(
2064 "HAVING translates for a single comparison".into(),
2065 ));
2066 }
2067 let e = kids[0];
2068 let op = self.prop_s(e, "op");
2069 let cmp_kids = self.arbor.children(e);
2070 let l = match self.kind(cmp_kids[0]).as_str() {
2071 "topic" | "recall" => "__AGG__".to_string(),
2072 _ => {
2073 return Err(SqlError::Unsupported(
2074 "HAVING compares the aggregate ($_ or its register)".into(),
2075 ));
2076 }
2077 };
2078 let r = self.operand(cmp_kids[1], None)?;
2079 Ok(format!("{l} {op} {r}"))
2080 }
2081
2082 /// `rec(...)` fields as a select list.
2083 fn record_fields(
2084 &mut self,
2085 s: NodeId,
2086 join: Option<(&str, &str)>,
2087 ) -> Result<Vec<String>, SqlError> {
2088 let kids = self.arbor.children(s);
2089 let mut fields = Vec::new();
2090 let mut i = 0;
2091 while i < kids.len() {
2092 let k = kids[i];
2093 if self.kind(k) == "literal" {
2094 let name = self.prop_s(k, "value");
2095 let value = self.operand(kids[i + 1], join.map(|(l, _)| l))?;
2096 let name = self.alias(&name)?;
2097 fields.push(format!("{value} AS {name}"));
2098 i += 2;
2099 } else {
2100 let value = self.operand(k, join.map(|(l, _)| l))?;
2101 fields.push(value);
2102 i += 1;
2103 }
2104 }
2105 Ok(fields)
2106 }
2107}
2108
2109fn sql_agg(name: &str, col: Option<String>) -> Result<String, SqlError> {
2110 let f = match name {
2111 "count" => "COUNT",
2112 "sum" => "SUM",
2113 "mean" | "avg" => "AVG",
2114 "min" => "MIN",
2115 "max" => "MAX",
2116 _ => unreachable!("checked by caller"),
2117 };
2118 match col {
2119 Some(c) => Ok(format!("{f}({c})")),
2120 // Only COUNT aggregates bare rows; SUM(*) and friends are
2121 // not SQL.
2122 None if f == "COUNT" => Ok("COUNT(*)".to_string()),
2123 None => Err(SqlError::Unsupported(format!(
2124 "'{name}' over row nodes (project a column first: '| ::col @| {name}')"
2125 ))),
2126 }
2127}
2128
2129#[cfg(test)]
2130mod null_and_literal_tests {
2131 use super::{Dialect, export, partial_pushdown, pushdown};
2132
2133 // Grouped pipeline that never pushes, so `partial_pushdown` hinges
2134 // only on the leading predicate (mirrors the crate's partial gate).
2135 const GROUPED: &str = " | ::x @| group(\"g\", ::x) | count | .n | %.";
2136
2137 #[test]
2138 fn pattern_literal_pushes_as_like() {
2139 // Ruling #33: segments in order, a `%` per star, LIKE's
2140 // metacharacters escaped. Strict push only where LIKE is
2141 // provably case-sensitive (PostgreSQL); everywhere else the
2142 // pattern rides the scan.
2143 let q = "/users/*[::name == 'app-'*]::id";
2144 let p = pushdown(q, Some(Dialect::Postgres)).unwrap();
2145 assert_eq!(
2146 p.sql,
2147 "SELECT id FROM users WHERE name LIKE 'app-%' ESCAPE '\\'"
2148 );
2149 assert!(pushdown(q, Some(Dialect::Sqlite)).is_none());
2150 assert!(pushdown(q, Some(Dialect::MySql)).is_none());
2151 // contains and multi-segment shapes
2152 let p = pushdown("/users/*[::name == *'web'*]::id", Some(Dialect::Postgres)).unwrap();
2153 assert!(p.sql.contains("LIKE '%web%'"), "{}", p.sql);
2154 let p = pushdown(
2155 "/users/*[::name == *'a'*'.gz']::id",
2156 Some(Dialect::Postgres),
2157 )
2158 .unwrap();
2159 assert!(p.sql.contains("LIKE '%a%.gz'"), "{}", p.sql);
2160 // LIKE metacharacters in a segment are escaped
2161 let p = pushdown("/users/*[::name == *'50%_x'*]::id", Some(Dialect::Postgres)).unwrap();
2162 assert!(p.sql.contains("LIKE '%50\\%\\_x%'"), "{}", p.sql);
2163 // `!=` with a pattern rides the scan
2164 assert!(pushdown("/users/*[::name !== *'web'*]::id", Some(Dialect::Postgres)).is_none());
2165 // the `*=` alias gains the same PostgreSQL rung
2166 let p = pushdown("/users/*[::name *= 'web']::id", Some(Dialect::Postgres)).unwrap();
2167 assert!(p.sql.contains("LIKE '%web%'"), "{}", p.sql);
2168 assert!(pushdown("/users/*[::name *= 'web']::id", Some(Dialect::Sqlite)).is_none());
2169 }
2170
2171 #[test]
2172 fn json_column_path_pushdown_per_dialect() {
2173 // `[/col/a/b:: = 'lit']` navigates into a JSON column; each
2174 // dialect extracts the fixed path to text and compares.
2175 // Verified live against all five engines to match the graft.
2176 let q = "/orders/*[/data/meta/tier:: = 'gold']::id";
2177 let sql = |d| pushdown(q, Some(d)).unwrap().sql;
2178 assert_eq!(
2179 sql(Dialect::Postgres),
2180 "SELECT id FROM orders WHERE ((data::jsonb #>> '{meta,tier}') = 'gold')"
2181 );
2182 assert_eq!(
2183 sql(Dialect::MySql),
2184 "SELECT id FROM orders WHERE (JSON_VALID(data) AND \
2185 JSON_UNQUOTE(JSON_EXTRACT(data, '$.meta.tier')) = 'gold')"
2186 );
2187 assert_eq!(
2188 sql(Dialect::Sqlite),
2189 "SELECT id FROM orders WHERE (json_valid(data) AND \
2190 json_extract(data, '$.meta.tier') = 'gold')"
2191 );
2192 assert_eq!(
2193 sql(Dialect::Mssql),
2194 "SELECT id FROM orders WHERE (ISJSON(data) = 1 AND \
2195 JSON_VALUE(data, '$.meta.tier') = 'gold')"
2196 );
2197 assert_eq!(
2198 sql(Dialect::Oracle),
2199 "SELECT id FROM orders WHERE (JSON_VALUE(data, '$.meta.tier') = 'gold')"
2200 );
2201 // With no dialect, the JSON navigation is not pushable — it
2202 // falls back to the client-side graft.
2203 assert!(pushdown(q, None).is_none());
2204 }
2205
2206 #[test]
2207 fn json_pushdown_only_string_equality() {
2208 // Numeric comparison, `!=`, and a wildcard hop stay off the
2209 // pushdown path (they are not provably identical to the
2210 // graft), so they refuse and scan even with a dialect set.
2211 let d = Some(Dialect::Sqlite);
2212 assert!(pushdown("/o/*[/data/n:: > 2]::id", d).is_none());
2213 assert!(pushdown("/o/*[/data/tier:: != 'gold']::id", d).is_none());
2214 assert!(pushdown("/o/*[/data/items/*/sku:: = 'A1']::id", d).is_none());
2215 }
2216
2217 #[test]
2218 fn json_pushdown_refuses_numeric_looking_literal() {
2219 // value_eq coerces numeric-looking strings ('150' matches
2220 // the JSON number 150 and the string "150.0"); no engine's
2221 // extractor does, so the push would drop rows.
2222 let q = "/orders/*[/data/total:: = '150']::id";
2223 assert!(pushdown(q, Some(Dialect::Sqlite)).is_none());
2224 assert!(pushdown(q, Some(Dialect::Postgres)).is_none());
2225 }
2226
2227 #[test]
2228 fn partial_json_prefilters_sqlite() {
2229 // Exact string equality rides the strict gate.
2230 let p = partial_pushdown(
2231 "/orders/*[/payload/customer/geo/city:: = 'Lyon']::id",
2232 Some(Dialect::Sqlite),
2233 )
2234 .unwrap();
2235 assert_eq!(p.table, "orders");
2236 assert_eq!(
2237 p.where_sql,
2238 "(json_valid(payload) AND \
2239 json_extract(payload, '$.customer.geo.city') = 'Lyon')"
2240 );
2241 // Numeric comparison: typed compare, text values escape.
2242 let p = partial_pushdown(
2243 "/orders/*[/payload/total:: > 150]::id",
2244 Some(Dialect::Sqlite),
2245 )
2246 .unwrap();
2247 assert_eq!(
2248 p.where_sql,
2249 "(json_valid(payload) AND (json_extract(payload, '$.total') > 150 \
2250 OR json_type(payload, '$.total') = 'text'))"
2251 );
2252 // Bare existence.
2253 let p = partial_pushdown("/orders/*[/payload/gift]::id", Some(Dialect::Sqlite)).unwrap();
2254 assert_eq!(
2255 p.where_sql,
2256 "(json_valid(payload) AND json_type(payload, '$.gift') IS NOT NULL)"
2257 );
2258 // != floors at existence.
2259 let p = partial_pushdown(
2260 "/orders/*[/payload/status:: != 'x']::id",
2261 Some(Dialect::Sqlite),
2262 )
2263 .unwrap();
2264 assert_eq!(
2265 p.where_sql,
2266 "(json_valid(payload) AND json_type(payload, '$.status') IS NOT NULL)"
2267 );
2268 // A flipped literal flips the operator.
2269 let p = partial_pushdown(
2270 "/orders/*[150 < /payload/total::]::id",
2271 Some(Dialect::Sqlite),
2272 )
2273 .unwrap();
2274 assert!(p.where_sql.contains("> 150"), "{}", p.where_sql);
2275 // Without a dialect, JSON predicates stay on the engine.
2276 assert!(partial_pushdown("/orders/*[/payload/gift]::id", None).is_none());
2277 }
2278
2279 #[test]
2280 fn partial_json_prefilters_postgres() {
2281 let p = partial_pushdown(
2282 "/orders/*[/payload/total:: > 150]::id",
2283 Some(Dialect::Postgres),
2284 )
2285 .unwrap();
2286 assert_eq!(
2287 p.where_sql,
2288 "(CASE WHEN jsonb_typeof(payload::jsonb #> '{total}') = 'number' \
2289 THEN ((payload::jsonb #>> '{total}'))::float8 > 150 \
2290 WHEN jsonb_typeof(payload::jsonb #> '{total}') = 'string' \
2291 THEN true ELSE false END)"
2292 );
2293 let p = partial_pushdown("/orders/*[/payload/gift]::id", Some(Dialect::Postgres)).unwrap();
2294 assert_eq!(
2295 p.where_sql,
2296 "(jsonb_typeof(payload::jsonb #> '{gift}') IS NOT NULL)"
2297 );
2298 }
2299
2300 #[test]
2301 fn partial_skips_untranslatable_keeps_prefix() {
2302 // A refused predicate no prefilter covers is skipped — the
2303 // shorter conjunction is still a superset (the engine
2304 // re-applies the original) — rather than aborting the plan.
2305 let p = partial_pushdown(
2306 "/orders/*[::status = 'shipped'][/payload/line_items/*/sku:: = 'X']::id",
2307 Some(Dialect::Sqlite),
2308 )
2309 .unwrap();
2310 assert_eq!(p.where_sql, "status = 'shipped'");
2311 }
2312
2313 #[test]
2314 fn null_literal_compares_use_is_null() {
2315 // `= null` / `!= null` are provably identical to Quarb's
2316 // value_eq(NULL, …) semantics, never the always-UNKNOWN
2317 // `x = NULL`; they translate — and push — in both modes.
2318 assert_eq!(
2319 export("/t/*[::x = null] | ::x").unwrap().query,
2320 "SELECT x FROM t WHERE x IS NULL"
2321 );
2322 assert_eq!(
2323 export("/t/*[::x != null] | ::x").unwrap().query,
2324 "SELECT x FROM t WHERE x IS NOT NULL"
2325 );
2326 assert_eq!(
2327 pushdown("/t/*[::x = null] | ::x", None).unwrap().sql,
2328 "SELECT x FROM t WHERE x IS NULL"
2329 );
2330 assert_eq!(
2331 pushdown("/t/*[::x != null] | ::x", None).unwrap().sql,
2332 "SELECT x FROM t WHERE x IS NOT NULL"
2333 );
2334 }
2335
2336 #[test]
2337 fn ne_and_not_refuse_pushdown_but_display_diverges() {
2338 // `!=` against a non-null value and `not(...)` drop the NULL
2339 // rows Quarb keeps under SQL three-valued logic: the pushdown
2340 // paths refuse (and scan), full and partial alike.
2341 assert!(pushdown("/t/*[::x != 5] | ::x", None).is_none());
2342 assert!(pushdown("/t/*[!::x = 5] | ::x", None).is_none());
2343 assert!(partial_pushdown(&format!("/t/*[::x != 5]{GROUPED}"), None).is_none());
2344 assert!(partial_pushdown(&format!("/t/*[!::x = 5]{GROUPED}"), None).is_none());
2345 // The display translation still emits `<>`, flagged with a note.
2346 let t = export("/t/*[::x != 5] | ::x").unwrap();
2347 assert_eq!(t.query, "SELECT x FROM t WHERE x <> 5");
2348 assert!(t.notes.iter().any(|n| n.contains("NULL")));
2349 }
2350
2351 #[test]
2352 fn unescapable_text_literal_refuses_pushdown() {
2353 // A backslash (MySQL/BigQuery escape) or an apostrophe
2354 // (BigQuery rejects '' doubling) has no portable escaping, so
2355 // pushdown refuses; a clean literal still pushes.
2356 assert!(pushdown("/files/*[::path = \"C:\\temp\"] | ::path", None).is_none());
2357 assert!(pushdown("/t/*[::name = \"it's\"] | ::name", None).is_none());
2358 assert!(partial_pushdown(&format!("/t/*[::name = \"it's\"]{GROUPED}"), None).is_none());
2359 assert_eq!(
2360 pushdown("/t/*[::name = \"rare\"] | ::name", None)
2361 .unwrap()
2362 .sql,
2363 "SELECT name FROM t WHERE name = 'rare'"
2364 );
2365 }
2366}