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