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