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 fn column_operand(&self, o: NodeId) -> Option<String> {
1594 match self.kind(o).as_str() {
1595 "path" if self.kid(o, "step").is_none() => {}
1596 "context" => {}
1597 _ => return None,
1598 }
1599 let p = self.kid(o, "projection")?;
1600 if self.prop_s(p, "kind") != "property" {
1601 return None;
1602 }
1603 self.prop(p, "key").map(|k| k.to_string())
1604 }
1605
1606 /// Whether `o` is a text string literal (`'London'`).
1607 fn is_text_literal(&self, o: NodeId) -> bool {
1608 self.kind(o) == "literal" && self.prop_s(o, "type") == "text"
1609 }
1610
1611 /// If `o` is the one JSON-column-path shape pushdown handles —
1612 /// `/col/seg/seg…::`, a plain-navigation path (every hop `/`
1613 /// and a plain-identifier object key, no wildcards, no nested
1614 /// predicate), at least one segment past the column, ending in
1615 /// the bare `::` projection — return `(column, [json segments])`.
1616 /// Anything else is `None` (and falls back to the graft).
1617 fn json_path(&self, o: NodeId) -> Option<(String, Vec<String>)> {
1618 if self.kind(o) != "path" {
1619 return None;
1620 }
1621 let steps = self.kids(o, "step");
1622 if steps.len() < 2 {
1623 return None;
1624 }
1625 // The projection must be the bare `::` (default value of
1626 // the JSON leaf), not `::key` or a `:::`/`:` metadata form.
1627 let proj = self.kid(o, "projection")?;
1628 if self.prop_s(proj, "kind") != "property" || self.prop(proj, "key").is_some() {
1629 return None;
1630 }
1631 let mut names = Vec::with_capacity(steps.len());
1632 for s in &steps {
1633 if self.prop_s(*s, "axis") != "/"
1634 || self.prop_s(*s, "matcher-kind") != "name"
1635 || self.kid(*s, "predicate").is_some()
1636 {
1637 return None;
1638 }
1639 let name = self.prop_s(*s, "matcher");
1640 // Plain object keys only — no array indices, no
1641 // characters that would need per-dialect path escaping.
1642 let plain = !name.is_empty()
1643 && name
1644 .chars()
1645 .next()
1646 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
1647 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
1648 if !plain {
1649 return None;
1650 }
1651 names.push(name);
1652 }
1653 let col = names.remove(0);
1654 Some((col, names))
1655 }
1656
1657 /// A safe `AS` alias: bare when it is a plain identifier and
1658 /// not an SQL keyword; otherwise strict mode refuses (quoting
1659 /// dialects disagree) and export mode double-quotes, noting it.
1660 fn alias(&mut self, name: &str) -> Result<String, SqlError> {
1661 if is_plain_ident(name) {
1662 return Ok(name.to_string());
1663 }
1664 if self.strict {
1665 return Err(SqlError::Unsupported(format!(
1666 "pushdown: field name {name:?} needs SQL quoting \
1667 (dialects disagree); rename the field"
1668 )));
1669 }
1670 self.notes
1671 .push(format!("field name {name:?} double-quoted (ANSI)"));
1672 Ok(format!("\"{}\"", name.replace('"', "\"\"")))
1673 }
1674
1675 /// A table or column name rendered into SQL. Bare when it is a
1676 /// plain identifier; otherwise strict mode refuses — a name
1677 /// like `a OR b` would silently rewrite the emitted SQL's
1678 /// meaning, breaking the provably-identical guarantee (and a
1679 /// portable quoting does not exist) — and export mode
1680 /// double-quotes with a note.
1681 fn sql_ident(&mut self, name: &str, what: &str) -> Result<String, SqlError> {
1682 if is_plain_ident(name) {
1683 return Ok(name.to_string());
1684 }
1685 if self.strict {
1686 return Err(SqlError::Unsupported(format!(
1687 "pushdown: {what} {name:?} is not a plain SQL identifier"
1688 )));
1689 }
1690 self.notes
1691 .push(format!("{what} {name:?} double-quoted (ANSI)"));
1692 Ok(format!("\"{}\"", name.replace('"', "\"\"")))
1693 }
1694
1695 /// Split a joined-side predicate: `col = $*1::col2` equalities
1696 /// become the ON condition; everything else the WHERE.
1697 /// `ltable` and `rtable` are the tables' SQL renderings.
1698 fn split_join_pred(
1699 &mut self,
1700 p: NodeId,
1701 rtable: &str,
1702 on: &mut Vec<String>,
1703 wheres: &mut Vec<String>,
1704 ) -> Result<(), SqlError> {
1705 if self.prop_s(p, "kind") != "expr" {
1706 return Err(SqlError::Unsupported(
1707 "a positional predicate on the joined side".into(),
1708 ));
1709 }
1710 // Flatten top-level ANDs; each conjunct routes to ON or
1711 // WHERE.
1712 fn conjuncts(ex: &Exporter, e: NodeId, out: &mut Vec<NodeId>) {
1713 if ex.kind(e) == "and" {
1714 for c in ex.arbor.children(e) {
1715 conjuncts(ex, c, out);
1716 }
1717 } else {
1718 out.push(e);
1719 }
1720 }
1721 let mut parts = Vec::new();
1722 for c in self.arbor.children(p) {
1723 conjuncts(self, c, &mut parts);
1724 }
1725 for e in parts {
1726 let uses_driver = self.subtree_has_driver(e);
1727 self.in_on = true;
1728 let cond = self.pred_expr(e, Some(rtable));
1729 self.in_on = false;
1730 let cond = cond?;
1731 if uses_driver && self.kind(e) == "compare" && self.prop_s(e, "op") == "=" {
1732 // Record which joined-table columns the ON binds —
1733 // the uniqueness obligation (see
1734 // Pushdown::join_left). Collected from the arbor's
1735 // bare-column operand nodes, never from the
1736 // rendered SQL text, so neither a literal nor an
1737 // unusual column name can corrupt the obligation.
1738 self.collect_join_cols(e)?;
1739 on.push(cond);
1740 } else {
1741 wheres.push(cond);
1742 }
1743 }
1744 Ok(())
1745 }
1746
1747 /// The joined-table columns the ON equality binds: every bare
1748 /// column operand (`::col`) in `e`'s subtree, appended to the
1749 /// join obligation with their raw (catalog) names.
1750 fn collect_join_cols(&mut self, e: NodeId) -> Result<(), SqlError> {
1751 // The `$$0` side is the driver — not part of the joined
1752 // table's obligation.
1753 if self.is_driver(e) {
1754 return Ok(());
1755 }
1756 if self.kind(e) == "path"
1757 && self.kid(e, "step").is_none()
1758 && let Some(p) = self.kid(e, "projection")
1759 {
1760 let col = self.projection_col(p)?;
1761 self.join_on_cols.push(col);
1762 }
1763 for c in self.arbor.children(e) {
1764 self.collect_join_cols(c)?;
1765 }
1766 Ok(())
1767 }
1768
1769 /// `$$0`: a context operand of index 0 (the driver).
1770 fn is_driver(&self, n: NodeId) -> bool {
1771 self.kind(n) == "context" && matches!(self.prop(n, "index"), Some(Value::Int(0)))
1772 }
1773
1774 fn subtree_has_driver(&self, n: NodeId) -> bool {
1775 self.is_driver(n)
1776 || self
1777 .arbor
1778 .children(n)
1779 .into_iter()
1780 .any(|c| self.subtree_has_driver(c))
1781 }
1782
1783 /// Consume the pipeline into SELECT clauses.
1784 fn pipeline(
1785 &mut self,
1786 q: NodeId,
1787 sel: &mut Select,
1788 join: Option<(&str, &str)>,
1789 ) -> Result<(), SqlError> {
1790 let Some(pipe) = self.kid(q, "pipeline") else {
1791 return Ok(());
1792 };
1793 let commands: Vec<NodeId> = self.arbor.children(pipe);
1794 let mut i = 0;
1795 // A pending per-row value (`| ::col`) feeding an aggregate.
1796 let mut pending_col: Option<String> = None;
1797 // After a grouped reduction: (key, agg_sql, alias).
1798 let mut grouped: Option<(String, String, String)> = None;
1799
1800 while i < commands.len() {
1801 let s = commands[i];
1802 match self.kind(s).as_str() {
1803 "expr" => {
1804 let kids = self.arbor.children(s);
1805 pending_col = Some(self.operand(kids[0], join.map(|(l, _)| l))?);
1806 }
1807 "cmd" => {
1808 let name = self.prop_s(s, "name");
1809 match name.as_str() {
1810 "rec" | "record" => {
1811 sel.select = self.record_fields(s, join)?;
1812 }
1813 // A reducing aggregate on the plain pipe:
1814 // the grouped reduction.
1815 "count" | "sum" | "mean" | "avg" | "min" | "max" => {
1816 let (key, _, _) = grouped.as_ref().ok_or_else(|| {
1817 SqlError::Unsupported(format!(
1818 "'| {name}' outside a group (use '@| {name}')"
1819 ))
1820 })?;
1821 // Quarb's count counts every member,
1822 // nulls included: COUNT(*), never the
1823 // NULL-skipping COUNT(col).
1824 let col = if name == "count" {
1825 pending_col = None;
1826 None
1827 } else {
1828 pending_col.take()
1829 };
1830 let agg = sql_agg(&name, col)?;
1831 grouped = Some((key.clone(), agg.clone(), agg));
1832 }
1833 other => {
1834 // sort/unique/top and kin are spellable
1835 // in SQL but with the backend's own
1836 // collation and duplicate semantics —
1837 // name the divergence, not a missing
1838 // feature.
1839 if matches!(other, "sort" | "sort_by" | "unique" | "top" | "bottom") {
1840 return Err(SqlError::Semantics(format!(
1841 "'{other}' means the backend's collation and \
1842 duplicate semantics; one engine-side ordering \
1843 keeps it identical everywhere"
1844 )));
1845 }
1846 return Err(SqlError::Unsupported(format!(
1847 "the '{other}' pipeline command"
1848 )));
1849 }
1850 }
1851 }
1852 "push" => {
1853 // An alias for the grouped aggregate.
1854 if let Some((k, agg, _)) = grouped.take() {
1855 let alias = self.prop_s(s, "name");
1856 grouped = Some((k, agg, alias));
1857 } else {
1858 return Err(SqlError::Unsupported(
1859 "a register push (Quarb-side state)".into(),
1860 ));
1861 }
1862 }
1863 "filter" => {
1864 if grouped.is_some() {
1865 // HAVING: `$_` / the alias refer to the
1866 // aggregate.
1867 let cond = self.having_cond(s)?;
1868 sel.having = Some(cond);
1869 } else {
1870 return Err(SqlError::Unsupported(
1871 "a mid-pipeline filter (put it in the row predicate)".into(),
1872 ));
1873 }
1874 }
1875 "recall" => {
1876 // `| %.` finalizes the grouped record.
1877 if self.prop_s(s, "ref") != "%." {
1878 return Err(SqlError::Unsupported(
1879 "a register recall (Quarb-side state)".into(),
1880 ));
1881 }
1882 let (key, agg, alias) = grouped.clone().ok_or_else(|| {
1883 SqlError::Unsupported("'%.', with nothing grouped".into())
1884 })?;
1885 let alias = self.alias(&alias)?;
1886 sel.select = vec![key.clone(), format!("{agg} AS {alias}")];
1887 // The HAVING condition compared the aggregate
1888 // through $_ — substitute the real expression.
1889 if let Some(h) = sel.having.take() {
1890 sel.having = Some(h.replace("__AGG__", &agg));
1891 }
1892 }
1893 "agg" => {
1894 let name = self.prop_s(s, "name");
1895 match name.as_str() {
1896 "count" | "sum" | "mean" | "avg" | "min" | "max" => {
1897 // Quarb's count counts every row, nulls
1898 // included: COUNT(*) regardless of a
1899 // pending column.
1900 let col = if name == "count" {
1901 pending_col = None;
1902 None
1903 } else {
1904 pending_col.take()
1905 };
1906 sel.select = vec![sql_agg(&name, col)?];
1907 self.aggregate = true;
1908 }
1909 "group" => {
1910 if self.strict {
1911 return Err(SqlError::Semantics(
1912 "GROUP BY result order is unordered in SQL".into(),
1913 ));
1914 }
1915 self.notes.push(
1916 "GROUP BY: SQL keeps a NULL-key group; Quarb's group \
1917 drops null keys"
1918 .to_string(),
1919 );
1920 let key = self.group_key(s, join)?;
1921 sel.group_by = Some(key.clone());
1922 grouped = Some((key, String::new(), String::new()));
1923 }
1924 "sort_by" => {
1925 if self.strict {
1926 return Err(SqlError::Semantics(
1927 "ORDER BY collations differ per engine".into(),
1928 ));
1929 }
1930 // A sort after a positional selection
1931 // (or a second sort) cannot render: the
1932 // fixed SELECT shape orders before
1933 // LIMIT, and has one ORDER BY.
1934 if sel.limit.is_some() {
1935 return Err(SqlError::Unsupported(
1936 "a sort after a positional selection (SQL orders \
1937 before LIMIT)"
1938 .into(),
1939 ));
1940 }
1941 if sel.order_by.is_some() {
1942 return Err(SqlError::Unsupported(
1943 "a second sort (SQL has a single ORDER BY)".into(),
1944 ));
1945 }
1946 let kids = self.arbor.children(s);
1947 let e = self.operand(kids[0], join.map(|(l, _)| l))?;
1948 sel.order_by = Some((e, false));
1949 }
1950 "reverse" => match &mut sel.order_by {
1951 Some((_, desc)) => *desc = true,
1952 None => {
1953 return Err(SqlError::Unsupported(
1954 "reverse without an ORDER BY (rows are unordered)".into(),
1955 ));
1956 }
1957 },
1958 "top" => {
1959 if self.strict {
1960 return Err(SqlError::Semantics(
1961 "ORDER BY collations differ per engine".into(),
1962 ));
1963 }
1964 if sel.limit.is_some() {
1965 return Err(SqlError::Unsupported(
1966 "'top' after a positional selection (SQL orders \
1967 before LIMIT)"
1968 .into(),
1969 ));
1970 }
1971 if sel.order_by.is_some() {
1972 return Err(SqlError::Unsupported(
1973 "a second sort (SQL has a single ORDER BY)".into(),
1974 ));
1975 }
1976 let kids = self.arbor.children(s);
1977 let n = self.prop_s(kids[0], "value");
1978 let e = self.operand(kids[1], join.map(|(l, _)| l))?;
1979 sel.order_by = Some((e, true));
1980 sel.limit = Some(n);
1981 }
1982 "unique" => {
1983 if self.strict {
1984 return Err(SqlError::Unsupported(
1985 "pushdown: DISTINCT result order is unordered in SQL".into(),
1986 ));
1987 }
1988 // Quarb dedups the limited rows; SQL
1989 // applies DISTINCT before LIMIT.
1990 if sel.limit.is_some() {
1991 return Err(SqlError::Unsupported(
1992 "'unique' after a positional selection (SQL applies \
1993 DISTINCT before LIMIT)"
1994 .into(),
1995 ));
1996 }
1997 if let Some(c) = pending_col.take() {
1998 sel.select = vec![c];
1999 }
2000 sel.distinct = true;
2001 }
2002 other => {
2003 return Err(SqlError::Unsupported(format!("the '{other}' aggregate")));
2004 }
2005 }
2006 }
2007 "select" => {
2008 if self.strict {
2009 return Err(SqlError::Unsupported(
2010 "pushdown: LIMIT without a guaranteed order".into(),
2011 ));
2012 }
2013 // `@| [..n]` → LIMIT.
2014 if sel.limit.is_some() {
2015 return Err(SqlError::Unsupported(
2016 "a second positional selection".into(),
2017 ));
2018 }
2019 let p = self.arbor.children(s)[0];
2020 match (self.prop_s(p, "kind").as_str(), self.prop(p, "to")) {
2021 ("range", Some(Value::Int(n)))
2022 if self.prop(p, "from").is_none() && n > 0 =>
2023 {
2024 sel.limit = Some(n.to_string());
2025 }
2026 _ => {
2027 return Err(SqlError::Unsupported(
2028 "positional selection beyond '@| [..n]'".into(),
2029 ));
2030 }
2031 }
2032 }
2033 other => {
2034 return Err(SqlError::Unsupported(format!(
2035 "the '{other}' command (windows, subcontexts, and registers are \
2036 Quarb-side state)"
2037 )));
2038 }
2039 }
2040 i += 1;
2041 }
2042 // A pending column with no aggregate is a one-column select.
2043 if let Some(c) = pending_col
2044 && sel.select.is_empty()
2045 {
2046 sel.select = vec![c];
2047 }
2048 Ok(())
2049 }
2050
2051 fn group_key(&mut self, s: NodeId, join: Option<(&str, &str)>) -> Result<String, SqlError> {
2052 let kids = self.arbor.children(s);
2053 // group(::k) or group("name", expr) — the key expression is
2054 // the last child; a literal first child is its name.
2055 let key = kids
2056 .iter()
2057 .rev()
2058 .find(|&&k| self.kind(k) != "literal")
2059 .ok_or_else(|| SqlError::Unsupported("a literal group key".into()))?;
2060 self.operand(*key, join.map(|(l, _)| l))
2061 }
2062
2063 /// A HAVING filter: `$_` and `$.name` refer to the aggregate.
2064 fn having_cond(&mut self, s: NodeId) -> Result<String, SqlError> {
2065 let kids = self.arbor.children(s);
2066 if kids.len() != 1 || self.kind(kids[0]) != "compare" {
2067 return Err(SqlError::Unsupported(
2068 "HAVING translates for a single comparison".into(),
2069 ));
2070 }
2071 let e = kids[0];
2072 let op = self.prop_s(e, "op");
2073 let cmp_kids = self.arbor.children(e);
2074 let l = match self.kind(cmp_kids[0]).as_str() {
2075 "topic" | "recall" => "__AGG__".to_string(),
2076 _ => {
2077 return Err(SqlError::Unsupported(
2078 "HAVING compares the aggregate ($_ or its register)".into(),
2079 ));
2080 }
2081 };
2082 let r = self.operand(cmp_kids[1], None)?;
2083 Ok(format!("{l} {op} {r}"))
2084 }
2085
2086 /// `rec(...)` fields as a select list.
2087 fn record_fields(
2088 &mut self,
2089 s: NodeId,
2090 join: Option<(&str, &str)>,
2091 ) -> Result<Vec<String>, SqlError> {
2092 let kids = self.arbor.children(s);
2093 let mut fields = Vec::new();
2094 let mut i = 0;
2095 while i < kids.len() {
2096 let k = kids[i];
2097 if self.kind(k) == "literal" {
2098 let name = self.prop_s(k, "value");
2099 let value = self.operand(kids[i + 1], join.map(|(l, _)| l))?;
2100 let name = self.alias(&name)?;
2101 fields.push(format!("{value} AS {name}"));
2102 i += 2;
2103 } else {
2104 let value = self.operand(k, join.map(|(l, _)| l))?;
2105 fields.push(value);
2106 i += 1;
2107 }
2108 }
2109 Ok(fields)
2110 }
2111}
2112
2113fn sql_agg(name: &str, col: Option<String>) -> Result<String, SqlError> {
2114 let f = match name {
2115 "count" => "COUNT",
2116 "sum" => "SUM",
2117 "mean" | "avg" => "AVG",
2118 "min" => "MIN",
2119 "max" => "MAX",
2120 _ => unreachable!("checked by caller"),
2121 };
2122 match col {
2123 Some(c) => Ok(format!("{f}({c})")),
2124 // Only COUNT aggregates bare rows; SUM(*) and friends are
2125 // not SQL.
2126 None if f == "COUNT" => Ok("COUNT(*)".to_string()),
2127 None => Err(SqlError::Unsupported(format!(
2128 "'{name}' over row nodes (project a column first: '| ::col @| {name}')"
2129 ))),
2130 }
2131}
2132
2133#[cfg(test)]
2134mod null_and_literal_tests {
2135 use super::{Dialect, export, partial_pushdown, pushdown};
2136
2137 // Grouped pipeline that never pushes, so `partial_pushdown` hinges
2138 // only on the leading predicate (mirrors the crate's partial gate).
2139 const GROUPED: &str = " | ::x @| group(\"g\", ::x) | count | .n | %.";
2140
2141 #[test]
2142 fn pattern_literal_pushes_as_like() {
2143 // Ruling #33: segments in order, a `%` per star, LIKE's
2144 // metacharacters escaped. Strict push only where LIKE is
2145 // provably case-sensitive (PostgreSQL); everywhere else the
2146 // pattern rides the scan.
2147 let q = "/users/*[::name == 'app-'*]::id";
2148 let p = pushdown(q, Some(Dialect::Postgres)).unwrap();
2149 assert_eq!(
2150 p.sql,
2151 "SELECT id FROM users WHERE name LIKE 'app-%' ESCAPE '\\'"
2152 );
2153 assert!(pushdown(q, Some(Dialect::Sqlite)).is_none());
2154 assert!(pushdown(q, Some(Dialect::MySql)).is_none());
2155 // contains and multi-segment shapes
2156 let p = pushdown("/users/*[::name == *'web'*]::id", Some(Dialect::Postgres)).unwrap();
2157 assert!(p.sql.contains("LIKE '%web%'"), "{}", p.sql);
2158 let p = pushdown(
2159 "/users/*[::name == *'a'*'.gz']::id",
2160 Some(Dialect::Postgres),
2161 )
2162 .unwrap();
2163 assert!(p.sql.contains("LIKE '%a%.gz'"), "{}", p.sql);
2164 // LIKE metacharacters in a segment are escaped
2165 let p = pushdown("/users/*[::name == *'50%_x'*]::id", Some(Dialect::Postgres)).unwrap();
2166 assert!(p.sql.contains("LIKE '%50\\%\\_x%'"), "{}", p.sql);
2167 // `!=` with a pattern rides the scan
2168 assert!(pushdown("/users/*[::name !== *'web'*]::id", Some(Dialect::Postgres)).is_none());
2169 // the `*=` alias gains the same PostgreSQL rung
2170 let p = pushdown("/users/*[::name *= 'web']::id", Some(Dialect::Postgres)).unwrap();
2171 assert!(p.sql.contains("LIKE '%web%'"), "{}", p.sql);
2172 assert!(pushdown("/users/*[::name *= 'web']::id", Some(Dialect::Sqlite)).is_none());
2173 }
2174
2175 #[test]
2176 fn json_column_path_pushdown_per_dialect() {
2177 // `[/col/a/b:: = 'lit']` navigates into a JSON column; each
2178 // dialect extracts the fixed path to text and compares.
2179 // Verified live against all five engines to match the graft.
2180 let q = "/orders/*[/data/meta/tier:: = 'gold']::id";
2181 let sql = |d| pushdown(q, Some(d)).unwrap().sql;
2182 assert_eq!(
2183 sql(Dialect::Postgres),
2184 "SELECT id FROM orders WHERE ((data::jsonb #>> '{meta,tier}') = 'gold')"
2185 );
2186 assert_eq!(
2187 sql(Dialect::MySql),
2188 "SELECT id FROM orders WHERE (JSON_VALID(data) AND \
2189 JSON_UNQUOTE(JSON_EXTRACT(data, '$.meta.tier')) = 'gold')"
2190 );
2191 assert_eq!(
2192 sql(Dialect::Sqlite),
2193 "SELECT id FROM orders WHERE (json_valid(data) AND \
2194 json_extract(data, '$.meta.tier') = 'gold')"
2195 );
2196 assert_eq!(
2197 sql(Dialect::Mssql),
2198 "SELECT id FROM orders WHERE (ISJSON(data) = 1 AND \
2199 JSON_VALUE(data, '$.meta.tier') = 'gold')"
2200 );
2201 assert_eq!(
2202 sql(Dialect::Oracle),
2203 "SELECT id FROM orders WHERE (JSON_VALUE(data, '$.meta.tier') = 'gold')"
2204 );
2205 // With no dialect, the JSON navigation is not pushable — it
2206 // falls back to the client-side graft.
2207 assert!(pushdown(q, None).is_none());
2208 }
2209
2210 #[test]
2211 fn json_pushdown_only_string_equality() {
2212 // Numeric comparison, `!=`, and a wildcard hop stay off the
2213 // pushdown path (they are not provably identical to the
2214 // graft), so they refuse and scan even with a dialect set.
2215 let d = Some(Dialect::Sqlite);
2216 assert!(pushdown("/o/*[/data/n:: > 2]::id", d).is_none());
2217 assert!(pushdown("/o/*[/data/tier:: != 'gold']::id", d).is_none());
2218 assert!(pushdown("/o/*[/data/items/*/sku:: = 'A1']::id", d).is_none());
2219 }
2220
2221 #[test]
2222 fn json_pushdown_refuses_numeric_looking_literal() {
2223 // value_eq coerces numeric-looking strings ('150' matches
2224 // the JSON number 150 and the string "150.0"); no engine's
2225 // extractor does, so the push would drop rows.
2226 let q = "/orders/*[/data/total:: = '150']::id";
2227 assert!(pushdown(q, Some(Dialect::Sqlite)).is_none());
2228 assert!(pushdown(q, Some(Dialect::Postgres)).is_none());
2229 }
2230
2231 #[test]
2232 fn partial_json_prefilters_sqlite() {
2233 // Exact string equality rides the strict gate.
2234 let p = partial_pushdown(
2235 "/orders/*[/payload/customer/geo/city:: = 'Lyon']::id",
2236 Some(Dialect::Sqlite),
2237 )
2238 .unwrap();
2239 assert_eq!(p.table, "orders");
2240 assert_eq!(
2241 p.where_sql,
2242 "(json_valid(payload) AND \
2243 json_extract(payload, '$.customer.geo.city') = 'Lyon')"
2244 );
2245 // Numeric comparison: typed compare, text values escape.
2246 let p = partial_pushdown(
2247 "/orders/*[/payload/total:: > 150]::id",
2248 Some(Dialect::Sqlite),
2249 )
2250 .unwrap();
2251 assert_eq!(
2252 p.where_sql,
2253 "(json_valid(payload) AND (json_extract(payload, '$.total') > 150 \
2254 OR json_type(payload, '$.total') = 'text'))"
2255 );
2256 // Bare existence.
2257 let p = partial_pushdown("/orders/*[/payload/gift]::id", Some(Dialect::Sqlite)).unwrap();
2258 assert_eq!(
2259 p.where_sql,
2260 "(json_valid(payload) AND json_type(payload, '$.gift') IS NOT NULL)"
2261 );
2262 // != floors at existence.
2263 let p = partial_pushdown(
2264 "/orders/*[/payload/status:: != 'x']::id",
2265 Some(Dialect::Sqlite),
2266 )
2267 .unwrap();
2268 assert_eq!(
2269 p.where_sql,
2270 "(json_valid(payload) AND json_type(payload, '$.status') IS NOT NULL)"
2271 );
2272 // A flipped literal flips the operator.
2273 let p = partial_pushdown(
2274 "/orders/*[150 < /payload/total::]::id",
2275 Some(Dialect::Sqlite),
2276 )
2277 .unwrap();
2278 assert!(p.where_sql.contains("> 150"), "{}", p.where_sql);
2279 // Without a dialect, JSON predicates stay on the engine.
2280 assert!(partial_pushdown("/orders/*[/payload/gift]::id", None).is_none());
2281 }
2282
2283 #[test]
2284 fn partial_json_prefilters_postgres() {
2285 let p = partial_pushdown(
2286 "/orders/*[/payload/total:: > 150]::id",
2287 Some(Dialect::Postgres),
2288 )
2289 .unwrap();
2290 assert_eq!(
2291 p.where_sql,
2292 "(CASE WHEN jsonb_typeof(payload::jsonb #> '{total}') = 'number' \
2293 THEN ((payload::jsonb #>> '{total}'))::float8 > 150 \
2294 WHEN jsonb_typeof(payload::jsonb #> '{total}') = 'string' \
2295 THEN true ELSE false END)"
2296 );
2297 let p = partial_pushdown("/orders/*[/payload/gift]::id", Some(Dialect::Postgres)).unwrap();
2298 assert_eq!(
2299 p.where_sql,
2300 "(jsonb_typeof(payload::jsonb #> '{gift}') IS NOT NULL)"
2301 );
2302 }
2303
2304 #[test]
2305 fn partial_skips_untranslatable_keeps_prefix() {
2306 // A refused predicate no prefilter covers is skipped — the
2307 // shorter conjunction is still a superset (the engine
2308 // re-applies the original) — rather than aborting the plan.
2309 let p = partial_pushdown(
2310 "/orders/*[::status = 'shipped'][/payload/line_items/*/sku:: = 'X']::id",
2311 Some(Dialect::Sqlite),
2312 )
2313 .unwrap();
2314 assert_eq!(p.where_sql, "status = 'shipped'");
2315 }
2316
2317 #[test]
2318 fn null_literal_compares_use_is_null() {
2319 // `= null` / `!= null` are provably identical to Quarb's
2320 // value_eq(NULL, …) semantics, never the always-UNKNOWN
2321 // `x = NULL`; they translate — and push — in both modes.
2322 assert_eq!(
2323 export("/t/*[::x = null] | ::x").unwrap().query,
2324 "SELECT x FROM t WHERE x IS NULL"
2325 );
2326 assert_eq!(
2327 export("/t/*[::x != null] | ::x").unwrap().query,
2328 "SELECT x FROM t WHERE x IS NOT NULL"
2329 );
2330 assert_eq!(
2331 pushdown("/t/*[::x = null] | ::x", None).unwrap().sql,
2332 "SELECT x FROM t WHERE x IS NULL"
2333 );
2334 assert_eq!(
2335 pushdown("/t/*[::x != null] | ::x", None).unwrap().sql,
2336 "SELECT x FROM t WHERE x IS NOT NULL"
2337 );
2338 }
2339
2340 #[test]
2341 fn ne_and_not_refuse_pushdown_but_display_diverges() {
2342 // `!=` against a non-null value and `not(...)` drop the NULL
2343 // rows Quarb keeps under SQL three-valued logic: the pushdown
2344 // paths refuse (and scan), full and partial alike.
2345 assert!(pushdown("/t/*[::x != 5] | ::x", None).is_none());
2346 assert!(pushdown("/t/*[!::x = 5] | ::x", None).is_none());
2347 assert!(partial_pushdown(&format!("/t/*[::x != 5]{GROUPED}"), None).is_none());
2348 assert!(partial_pushdown(&format!("/t/*[!::x = 5]{GROUPED}"), None).is_none());
2349 // The display translation still emits `<>`, flagged with a note.
2350 let t = export("/t/*[::x != 5] | ::x").unwrap();
2351 assert_eq!(t.query, "SELECT x FROM t WHERE x <> 5");
2352 assert!(t.notes.iter().any(|n| n.contains("NULL")));
2353 }
2354
2355 #[test]
2356 fn unescapable_text_literal_refuses_pushdown() {
2357 // A backslash (MySQL/BigQuery escape) or an apostrophe
2358 // (BigQuery rejects '' doubling) has no portable escaping, so
2359 // pushdown refuses; a clean literal still pushes.
2360 assert!(pushdown("/files/*[::path = \"C:\\temp\"] | ::path", None).is_none());
2361 assert!(pushdown("/t/*[::name = \"it's\"] | ::name", None).is_none());
2362 assert!(partial_pushdown(&format!("/t/*[::name = \"it's\"]{GROUPED}"), None).is_none());
2363 assert_eq!(
2364 pushdown("/t/*[::name = \"rare\"] | ::name", None)
2365 .unwrap()
2366 .sql,
2367 "SELECT name FROM t WHERE name = 'rare'"
2368 );
2369 }
2370}