ddx_core/rewrite.rs
1// SPDX-FileCopyrightText: 2026 Alexander Merose <al@merose.com> & ddx Authors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Source-to-source SQL rewriting: find every `grad`/`jvp` marker, replace it
6//! with derivative SQL, leave everything else byte-identical.
7//!
8//! This is Path A (design.md §3.3), the universal path every target relies on.
9//! It is a real subsystem, not a one-liner (design.md §3.2):
10//!
11//! * a **parse-free pre-gate** so a marker-free statement is never parsed, and
12//! so a `sqlparser` coverage gap can only ever bound a statement that
13//! *actually contains* a marker (F5);
14//! * **splice by source span**, so everything outside a marker stays
15//! byte-identical — which requires a UTF-8-aware character-column→byte-offset
16//! conversion, because `sqlparser` spans are 1-based *characters*, not bytes
17//! (G3);
18//! * **multiple and nested markers** — spliced in reverse source order, nested
19//! ones differentiated bottom-up (`grad(grad(f,x),x)` just works);
20//! * a safe **fallback** to whole-statement reprinting on the empty spans the
21//! API documents as possible.
22//!
23//! Two guards run here, both catching a *silently-wrong* derivative and turning
24//! it into a typed error: the ambiguity guard lives in the engine (F2), and the
25//! CTE-computed-alias guard (F3/G4) lives in [`projection_guard`].
26
27use std::collections::HashSet;
28use std::fmt;
29use std::ops::ControlFlow;
30
31use sqlparser::ast::Spanned;
32use sqlparser::ast::{
33 Expr, Function, ObjectNamePart, Query, Select, SelectItem, SetExpr, Statement, TableFactor,
34 Visit, VisitMut, Visitor, VisitorMut,
35};
36use sqlparser::dialect::Dialect;
37use sqlparser::parser::Parser;
38use sqlparser::tokenizer::{Location, Span, Token, TokenWithSpan, Tokenizer};
39
40use crate::colref::{ColRef, IdentCasing};
41use crate::engine::{differentiate, jvp, positional_args, RuleRegistry};
42use crate::error::{DiffError, Result};
43
44/// Which marker a function call is.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub(crate) enum MarkerKind {
47 Grad,
48 Jvp,
49}
50
51/// Classify a function call as a marker — but only an **unqualified**,
52/// case-folded `grad`/`jvp` (design.md §3.2, F8). `myschema.grad(...)` and a
53/// user's own multi-part function are left alone.
54pub(crate) fn marker_kind(f: &Function) -> Option<MarkerKind> {
55 if f.name.0.len() != 1 {
56 return None;
57 }
58 let ObjectNamePart::Identifier(id) = &f.name.0[0] else {
59 return None;
60 };
61 match id.value.to_ascii_lowercase().as_str() {
62 "grad" => Some(MarkerKind::Grad),
63 "jvp" => Some(MarkerKind::Jvp),
64 _ => None,
65 }
66}
67
68impl MarkerKind {
69 /// The marker's SQL function name, for display.
70 fn name(self) -> &'static str {
71 match self {
72 MarkerKind::Grad => "grad",
73 MarkerKind::Jvp => "jvp",
74 }
75 }
76}
77
78fn is_marker_expr(e: &Expr) -> bool {
79 matches!(e, Expr::Function(f) if marker_kind(f).is_some())
80}
81
82/// The [`MarkerKind`] of an expression that is a marker call, else `None`.
83fn marker_expr_kind(e: &Expr) -> Option<MarkerKind> {
84 match e {
85 Expr::Function(f) => marker_kind(f),
86 _ => None,
87 }
88}
89
90/// A human-inspectable account of what [`crate::Ddx::rewrite_sql`] would do to a
91/// statement, produced by [`crate::Ddx::explain`] — so a user can see the
92/// derivative SQL *before* running anything. Inspect the fields directly, or
93/// print the whole thing (`Display`) for a readable summary.
94#[derive(Debug, Clone)]
95pub struct Explanation {
96 /// The original statement, unchanged.
97 pub original: String,
98 /// The statement after every `grad`/`jvp` marker is rewritten to derivative
99 /// SQL — exactly what [`crate::Ddx::rewrite_sql`] returns.
100 pub rewritten: String,
101 /// One entry per top-level marker, in source order. Empty when the statement
102 /// has no marker, or in the rare empty-span reprint fallback (where the
103 /// rewrite still appears in [`Explanation::rewritten`]).
104 pub steps: Vec<ExplainStep>,
105}
106
107/// One marker and the derivative SQL it rewrites to (part of an [`Explanation`]).
108#[derive(Debug, Clone)]
109pub struct ExplainStep {
110 /// The marker function: `"grad"` or `"jvp"`.
111 pub function: &'static str,
112 /// The original marker call, exactly as written (e.g. `grad(sin(x), x)`).
113 pub marker: String,
114 /// The derivative SQL it becomes (e.g. `(cos(x))`).
115 pub derivative: String,
116}
117
118impl fmt::Display for Explanation {
119 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120 if self.steps.is_empty() {
121 return write!(
122 f,
123 "No grad/jvp markers to rewrite; the statement is unchanged:\n {}",
124 self.original
125 );
126 }
127 let n = self.steps.len();
128 writeln!(
129 f,
130 "ddx rewrites {n} marker{}:",
131 if n == 1 { "" } else { "s" }
132 )?;
133 for step in &self.steps {
134 writeln!(f, " • {} → {}", step.marker, step.derivative)?;
135 }
136 writeln!(f)?;
137 writeln!(f, " from: {}", self.original)?;
138 write!(f, " into: {}", self.rewritten)
139 }
140}
141
142/// The parse-free pre-gate: a case-insensitive scan for an *unqualified*
143/// `grad(`/`jvp(` — the equivalent of `(?i)(?:^|[^A-Za-z0-9_.])(grad|jvp)\s*\(`,
144/// hand-rolled so the core depends on `sqlparser` only (design.md §3.2/§6). A
145/// statement that doesn't hit is returned verbatim, never parsed (F5). It is a
146/// best-effort filter: a false positive (e.g. `grad(` inside a string literal)
147/// only costs a parse that then finds no marker, never a wrong rewrite.
148fn pre_gate_hit(sql: &str) -> bool {
149 // ASCII-lowercasing preserves byte length and offsets, so indices found in
150 // `lower` are valid char boundaries in `sql`.
151 let lower = sql.to_ascii_lowercase();
152 for kw in ["grad", "jvp"] {
153 let mut from = 0;
154 while let Some(rel) = lower[from..].find(kw) {
155 let idx = from + rel;
156 from = idx + 1;
157
158 // Preceding character must not be part of a longer identifier or a
159 // qualifier (`.`), so `mygrad(` and `schema.grad(` don't match.
160 let ok_prev = idx == 0
161 || sql[..idx].chars().next_back().is_some_and(|prev| {
162 !(prev.is_ascii_alphanumeric() || prev == '_' || prev == '.')
163 });
164 if !ok_prev {
165 continue;
166 }
167
168 // The next significant character must be `(`. sqlparser treats a SQL
169 // comment as lexical whitespace, so `grad /* c */ (x, x)` and
170 // `grad-- c\n(x, x)` are genuine marker calls — the scan skips
171 // comments as well as whitespace, or the gate would miss them and
172 // let a real marker reach execution un-rewritten (#52).
173 let after = &sql[idx + kw.len()..];
174 if after[skip_trivia(after)..].starts_with('(') {
175 return true;
176 }
177 }
178 }
179 false
180}
181
182/// Byte offset of the first significant character in `s`, skipping leading
183/// whitespace and SQL comments (`-- … end-of-line`, and `/* … */` block
184/// comments, which nest in Postgres/DuckDB) — the trivia sqlparser's tokenizer
185/// discards. Returns `s.len()` if the rest is all trivia.
186///
187/// Delimiters (`-`, `/`, `*`, whitespace, `\n`) are all ASCII, and a UTF-8
188/// continuation byte is never equal to an ASCII byte, so scanning by bytes is
189/// safe even with multibyte text inside a comment.
190fn skip_trivia(s: &str) -> usize {
191 let b = s.as_bytes();
192 let n = b.len();
193 let mut i = 0;
194 loop {
195 while i < n && b[i].is_ascii_whitespace() {
196 i += 1;
197 }
198 // Line comment: `--` to end of line (or input).
199 if i + 1 < n && b[i] == b'-' && b[i + 1] == b'-' {
200 i += 2;
201 while i < n && b[i] != b'\n' {
202 i += 1;
203 }
204 continue;
205 }
206 // Block comment: `/* … */`, nesting-aware.
207 if i + 1 < n && b[i] == b'/' && b[i + 1] == b'*' {
208 i += 2;
209 let mut depth = 1usize;
210 while i < n && depth > 0 {
211 if i + 1 < n && b[i] == b'/' && b[i + 1] == b'*' {
212 depth += 1;
213 i += 2;
214 } else if i + 1 < n && b[i] == b'*' && b[i + 1] == b'/' {
215 depth -= 1;
216 i += 2;
217 } else {
218 i += 1;
219 }
220 }
221 continue;
222 }
223 break;
224 }
225 i
226}
227
228/// The public entry point behind [`crate::Ddx::rewrite_sql`].
229pub(crate) fn rewrite_sql(
230 sql: &str,
231 dialect: &dyn Dialect,
232 casing: IdentCasing,
233 reg: &RuleRegistry,
234) -> Result<String> {
235 match resolve_markers(sql, dialect, casing, reg)? {
236 Resolution::Verbatim => Ok(sql.to_string()),
237 Resolution::Reprinted(out) => Ok(out),
238 Resolution::Spliced(repls) => Ok(apply_splice(sql, repls)),
239 }
240}
241
242/// The public entry point behind [`crate::Ddx::explain`]: the same marker
243/// resolution as [`rewrite_sql`], but returned as inspectable structure (each
244/// marker and the derivative SQL it becomes) plus the final rewritten
245/// statement — so a user can see what will happen before running anything.
246pub(crate) fn explain_sql(
247 sql: &str,
248 dialect: &dyn Dialect,
249 casing: IdentCasing,
250 reg: &RuleRegistry,
251) -> Result<Explanation> {
252 let (rewritten, steps) = match resolve_markers(sql, dialect, casing, reg)? {
253 Resolution::Verbatim => (sql.to_string(), Vec::new()),
254 // The empty-span fallback reprints the whole statement, so per-marker
255 // byte ranges aren't available — report the rewrite without steps.
256 Resolution::Reprinted(out) => (out, Vec::new()),
257 Resolution::Spliced(repls) => {
258 let steps = repls
259 .iter()
260 .map(|r| ExplainStep {
261 function: r.function.name(),
262 marker: r.marker.clone(),
263 derivative: r.derivative.clone(),
264 })
265 .collect();
266 (apply_splice(sql, repls), steps)
267 }
268 };
269 Ok(Explanation {
270 original: sql.to_string(),
271 rewritten,
272 steps,
273 })
274}
275
276/// Splice each replacement's derivative into `sql` by byte range, in reverse
277/// source order so earlier offsets stay valid.
278fn apply_splice(sql: &str, mut repls: Vec<Replacement>) -> String {
279 repls.sort_by_key(|r| std::cmp::Reverse(r.start));
280 let mut out = sql.to_string();
281 for r in repls {
282 out.replace_range(r.start..r.end, &r.derivative);
283 }
284 out
285}
286
287/// One marker's resolution: the byte range it occupies, its original call text,
288/// and the derivative SQL it becomes.
289struct Replacement {
290 start: usize,
291 end: usize,
292 function: MarkerKind,
293 marker: String,
294 derivative: String,
295}
296
297/// How a statement resolves against its markers.
298enum Resolution {
299 /// No real marker — the input is returned unchanged.
300 Verbatim,
301 /// The empty-span fallback: only the fully-rewritten text is available (no
302 /// per-marker byte ranges).
303 Reprinted(String),
304 /// The normal path: one [`Replacement`] per outermost marker.
305 Spliced(Vec<Replacement>),
306}
307
308/// Run the marker pipeline (pre-gate → parse → collect → per-marker derivative)
309/// *without* splicing, so both [`rewrite_sql`] and [`explain_sql`] share it.
310fn resolve_markers(
311 sql: &str,
312 dialect: &dyn Dialect,
313 casing: IdentCasing,
314 reg: &RuleRegistry,
315) -> Result<Resolution> {
316 // 1. Parse-free pre-gate: no marker syntax, no parse, byte-identical out.
317 if !pre_gate_hit(sql) {
318 return Ok(Resolution::Verbatim);
319 }
320
321 // 2. The statement (or one of them) looks like it carries a marker; parse.
322 let statements = Parser::parse_sql(dialect, sql)
323 .map_err(|e| DiffError::Parse(format!("failed to parse SQL: {e}")))?;
324
325 // 3. Statement-level context for the projection-boundary guard (F3/G4):
326 // the names of every *computed* select-list alias of a CTE/derived table.
327 let mut aliases = ComputedAliases::default();
328 for stmt in &statements {
329 collect_computed_aliases(stmt, &mut aliases);
330 }
331
332 // 4. Locate the outermost markers (with their source spans). Nested markers
333 // are handled when their enclosing outermost marker is differentiated.
334 let mut collector = MarkerCollector::default();
335 for stmt in &statements {
336 let _ = Visit::visit(stmt, &mut collector);
337 }
338 // Pre-gate false positive (e.g. only qualified markers, or `grad(` inside a
339 // string literal): nothing to rewrite.
340 if collector.found.is_empty() {
341 return Ok(Resolution::Verbatim);
342 }
343
344 // 5. Empty spans are documented as possible; fall back to a correct (if not
345 // byte-identical) whole-statement reprint if any marker lacks a span.
346 if collector.found.iter().any(|(span, _)| is_empty_span(span)) {
347 return Ok(Resolution::Reprinted(reprint_fallback(
348 statements, casing, reg, &aliases,
349 )?));
350 }
351
352 // 6. Compute each replacement's byte range and derivative.
353 //
354 // The marker name position (`span.start`) is reliable, but the Function's
355 // `span.end` is NOT: sqlparser under-reports it when the call's last
356 // argument ends in a `Cast` (excludes ` AS <type>`) or `Nested` (excludes
357 // the closing `)`), so trusting it under-splices and leaves corrupt SQL
358 // behind (#57). Instead, find the call's matching close paren over the
359 // token stream — re-tokenizing with the same dialect, which lexes strings
360 // and comments as single tokens so their parens don't miscount.
361 let tokens = Tokenizer::new(dialect, sql)
362 .tokenize_with_location()
363 .map_err(|e| DiffError::Parse(format!("failed to tokenize SQL: {e}")))?;
364
365 let mut repls = Vec::with_capacity(collector.found.len());
366 for (span, marker_expr) in &collector.found {
367 let derivative = differentiate_marker_tree(marker_expr, casing, reg, &aliases)?;
368 let function = marker_expr_kind(marker_expr)
369 .ok_or_else(|| DiffError::Internal("outermost marker lost its kind".into()))?;
370 let start = locate(sql, span.start, false)
371 .ok_or_else(|| DiffError::Internal("marker span start out of range".into()))?;
372 let close = marker_call_close(&tokens, span.start).ok_or_else(|| {
373 DiffError::Internal("could not locate the marker call's closing parenthesis".into())
374 })?;
375 // `close` is the location of the `)`; the exclusive byte end is one
376 // character past it.
377 let end = locate(sql, close, true)
378 .ok_or_else(|| DiffError::Internal("marker span end out of range".into()))?;
379 let marker = sql[start..end].to_string();
380 repls.push(Replacement {
381 start,
382 end,
383 function,
384 marker,
385 derivative,
386 });
387 }
388 Ok(Resolution::Spliced(repls))
389}
390
391/// Differentiate one (possibly nested) marker subtree, returning the derivative
392/// rendered to SQL text, parenthesized so it keeps the call's precedence.
393fn differentiate_marker_tree(
394 marker_expr: &Expr,
395 casing: IdentCasing,
396 reg: &RuleRegistry,
397 aliases: &ComputedAliases,
398) -> Result<String> {
399 let mut clone = marker_expr.clone();
400 let mut rw = MarkerRewriter {
401 casing,
402 reg,
403 aliases,
404 };
405 if let ControlFlow::Break(err) = VisitMut::visit(&mut clone, &mut rw) {
406 return Err(err);
407 }
408 Ok(clone.to_string())
409}
410
411/// The whole-statement reprint fallback (empty-span case).
412fn reprint_fallback(
413 mut statements: Vec<Statement>,
414 casing: IdentCasing,
415 reg: &RuleRegistry,
416 aliases: &ComputedAliases,
417) -> Result<String> {
418 for stmt in &mut statements {
419 let mut rw = MarkerRewriter {
420 casing,
421 reg,
422 aliases,
423 };
424 if let ControlFlow::Break(err) = VisitMut::visit(stmt, &mut rw) {
425 return Err(err);
426 }
427 }
428 Ok(statements
429 .iter()
430 .map(ToString::to_string)
431 .collect::<Vec<_>>()
432 .join("; "))
433}
434
435// ---------------------------------------------------------------------------
436// Differentiating a single marker (args assumed already marker-free)
437// ---------------------------------------------------------------------------
438
439/// Differentiate a single marker call whose arguments are already free of
440/// nested markers (guaranteed by the bottom-up post-order walk).
441fn differentiate_marker(f: &Function, casing: IdentCasing, reg: &RuleRegistry) -> Result<Expr> {
442 let kind = marker_kind(f).ok_or_else(|| DiffError::Internal("not a marker".into()))?;
443 let args = positional_args(f).ok_or_else(|| {
444 DiffError::InvalidMarker("marker call has non-positional arguments".into())
445 })?;
446 match kind {
447 MarkerKind::Grad => {
448 if args.len() != 2 {
449 return Err(DiffError::InvalidMarker(format!(
450 "grad(expr, column) expects 2 arguments, got {}",
451 args.len()
452 )));
453 }
454 let wrt = ColRef::from_wrt_arg("grad", args[1])?;
455 differentiate(args[0], &wrt, casing, reg)
456 }
457 MarkerKind::Jvp => {
458 if args.len() != 3 {
459 return Err(DiffError::InvalidMarker(format!(
460 "jvp(expr, column, tangent) expects 3 arguments, got {}",
461 args.len()
462 )));
463 }
464 let wrt = ColRef::from_wrt_arg("jvp", args[1])?;
465 let seeds = vec![(wrt, args[2].clone())];
466 jvp(args[0], &seeds, casing, reg)
467 }
468 }
469}
470
471/// The projection-boundary guard (design.md §3.5, F3/G4).
472///
473/// Errors if a marker argument references an identifier that is a *computed*
474/// select-list alias of a CTE/derived table in the same statement, used as a
475/// *non-`wrt`* term — differentiating it would silently treat an upstream
476/// expression as a constant and drop gradient terms. The carve-out (G4): when
477/// the alias *is* the `wrt` itself, every occurrence is the differentiation
478/// leaf, so no term can be dropped and the guard stays quiet.
479fn projection_guard(f: &Function, aliases: &ComputedAliases) -> Result<()> {
480 if aliases.is_empty() {
481 return Ok(());
482 }
483 let Some(args) = positional_args(f) else {
484 return Ok(());
485 };
486 let Some(expr_arg) = args.first() else {
487 return Ok(());
488 };
489 let wrt_name = args
490 .get(1)
491 .and_then(|a| ColRef::from_expr(a))
492 .map(|c| c.name.value.to_ascii_lowercase());
493
494 let mut cols = ColumnCollector::default();
495 let _ = Visit::visit(*expr_arg, &mut cols);
496 for c in cols.cols {
497 let lname = c.name.value.to_ascii_lowercase();
498 // Carve-out (G4): the wrt itself is always a leaf; never an error.
499 if Some(&lname) == wrt_name.as_ref() {
500 continue;
501 }
502 let is_boundary = match &c.qualifier {
503 // A bare occurrence could bind to any computed alias in scope.
504 None => aliases.bare.contains(&lname),
505 // A qualified occurrence crosses a projection boundary only if the
506 // qualifier names the relation that actually owns the alias. A base
507 // column qualified to an unrelated table (e.g. `w.s` when the alias
508 // `s` belongs to a different CTE) is NOT the alias — preserving the
509 // qualifier-awareness the F2 ambiguity guard is built on.
510 Some(q) => aliases
511 .qualified
512 .contains(&(q.value.to_ascii_lowercase(), lname.clone())),
513 };
514 if is_boundary {
515 return Err(DiffError::ProjectionBoundary(format!(
516 "`{}` is a computed select-list alias of a CTE/derived table used \
517 as a non-differentiation term; grad does not see through the \
518 projection boundary — differentiate inside that CTE instead",
519 c.display()
520 )));
521 }
522 }
523 Ok(())
524}
525
526// ---------------------------------------------------------------------------
527// Visitors
528// ---------------------------------------------------------------------------
529
530/// Collects the outermost marker expressions (with their spans), skipping
531/// markers nested inside another marker's arguments (handled bottom-up later).
532#[derive(Default)]
533struct MarkerCollector {
534 depth: usize,
535 found: Vec<(Span, Expr)>,
536}
537
538impl Visitor for MarkerCollector {
539 type Break = ();
540
541 fn pre_visit_expr(&mut self, expr: &Expr) -> ControlFlow<()> {
542 if is_marker_expr(expr) {
543 if self.depth == 0 {
544 self.found.push((expr.span(), expr.clone()));
545 }
546 self.depth += 1;
547 }
548 ControlFlow::Continue(())
549 }
550
551 fn post_visit_expr(&mut self, expr: &Expr) -> ControlFlow<()> {
552 if is_marker_expr(expr) {
553 self.depth -= 1;
554 }
555 ControlFlow::Continue(())
556 }
557}
558
559/// Replaces each marker with `Nested(derivative)`, bottom-up (post-order), so a
560/// nested marker's own arguments are already marker-free when it is reached.
561struct MarkerRewriter<'a> {
562 casing: IdentCasing,
563 reg: &'a RuleRegistry,
564 aliases: &'a ComputedAliases,
565}
566
567impl VisitorMut for MarkerRewriter<'_> {
568 type Break = DiffError;
569
570 fn post_visit_expr(&mut self, expr: &mut Expr) -> ControlFlow<DiffError> {
571 let replacement = match expr {
572 Expr::Function(f) if marker_kind(f).is_some() => {
573 if let Err(err) = projection_guard(f, self.aliases) {
574 return ControlFlow::Break(err);
575 }
576 match differentiate_marker(f, self.casing, self.reg) {
577 Ok(d) => Some(d),
578 Err(err) => return ControlFlow::Break(err),
579 }
580 }
581 _ => None,
582 };
583 if let Some(d) = replacement {
584 *expr = Expr::Nested(Box::new(d));
585 }
586 ControlFlow::Continue(())
587 }
588}
589
590/// Collects the column references directly appearing in an expression tree.
591#[derive(Default)]
592struct ColumnCollector {
593 cols: Vec<ColRef>,
594}
595
596impl Visitor for ColumnCollector {
597 type Break = ();
598
599 fn pre_visit_expr(&mut self, expr: &Expr) -> ControlFlow<()> {
600 match expr {
601 Expr::Identifier(_) | Expr::CompoundIdentifier(_) => {
602 if let Some(cr) = ColRef::from_expr(expr) {
603 self.cols.push(cr);
604 }
605 }
606 _ => {}
607 }
608 ControlFlow::Continue(())
609 }
610}
611
612// ---------------------------------------------------------------------------
613// Computed-alias collection for the projection-boundary guard
614// ---------------------------------------------------------------------------
615
616/// The *computed* select-list aliases of the CTEs/derived tables in a
617/// statement, recorded so the guard can distinguish a reference that crosses a
618/// projection boundary from a same-named base column that does not.
619#[derive(Default)]
620struct ComputedAliases {
621 /// Alias names referenceable by a *bare* (unqualified) occurrence.
622 bare: HashSet<String>,
623 /// `(owning relation name, alias name)` for CTE/derived-table computed
624 /// aliases — so a *qualified* occurrence `rel.alias` is matched only against
625 /// the relation that actually owns it. This is what keeps a base column
626 /// like `w.s` from colliding with an unrelated CTE alias `s` (F2's
627 /// qualifier-awareness, applied to the F3/G4 guard).
628 qualified: HashSet<(String, String)>,
629}
630
631impl ComputedAliases {
632 fn is_empty(&self) -> bool {
633 self.bare.is_empty() && self.qualified.is_empty()
634 }
635}
636
637fn collect_computed_aliases(stmt: &Statement, out: &mut ComputedAliases) {
638 match stmt {
639 Statement::Query(q) => walk_query(q, None, out),
640 Statement::Insert(insert) => {
641 if let Some(source) = &insert.source {
642 walk_query(source, None, out);
643 }
644 }
645 _ => {}
646 }
647}
648
649/// `owner` is the name of the relation whose *own* projection aliases we are
650/// collecting (a CTE name, or a derived-table alias) — `None` for the outer
651/// query's own select list, whose aliases can only be referenced bare.
652fn walk_query(q: &Query, owner: Option<&str>, out: &mut ComputedAliases) {
653 if let Some(with) = &q.with {
654 for cte in &with.cte_tables {
655 let name = cte.alias.name.value.to_ascii_lowercase();
656 walk_query(&cte.query, Some(&name), out);
657 }
658 }
659 walk_set_expr(&q.body, owner, out);
660}
661
662fn walk_set_expr(body: &SetExpr, owner: Option<&str>, out: &mut ComputedAliases) {
663 match body {
664 SetExpr::Select(select) => walk_select(select, owner, out),
665 SetExpr::Query(q) => walk_query(q, owner, out),
666 SetExpr::SetOperation { left, right, .. } => {
667 walk_set_expr(left, owner, out);
668 walk_set_expr(right, owner, out);
669 }
670 _ => {}
671 }
672}
673
674fn walk_select(select: &Select, owner: Option<&str>, out: &mut ComputedAliases) {
675 for item in &select.projection {
676 if let SelectItem::ExprWithAlias { expr, alias } = item {
677 // A *computed* alias is one whose projected expression is not a
678 // plain column reference. `ColRef::from_expr` is the single place
679 // that recognizes a column reference (seeing through `Nested`).
680 if ColRef::from_expr(expr).is_none() {
681 let name = alias.value.to_ascii_lowercase();
682 if let Some(o) = owner {
683 out.qualified.insert((o.to_string(), name.clone()));
684 }
685 out.bare.insert(name);
686 }
687 }
688 }
689 // Recurse into derived tables (FROM subqueries), which are projection
690 // boundaries too — a derived table's aliases are owned by its own alias.
691 for twj in &select.from {
692 walk_table_factor(&twj.relation, out);
693 for join in &twj.joins {
694 walk_table_factor(&join.relation, out);
695 }
696 }
697}
698
699fn walk_table_factor(tf: &TableFactor, out: &mut ComputedAliases) {
700 if let TableFactor::Derived {
701 subquery, alias, ..
702 } = tf
703 {
704 let owner = alias.as_ref().map(|a| a.name.value.to_ascii_lowercase());
705 walk_query(subquery, owner.as_deref(), out);
706 }
707}
708
709/// The [`Location`] of the `)` that closes the marker call whose name token
710/// starts at `name_start`, found by matching parentheses over the token stream.
711///
712/// This is authoritative where `Expr::span()` is not: sqlparser under-reports a
713/// `Function`'s span end when its last argument ends in a `Cast` (the ` AS
714/// <type>` tail is excluded) or a `Nested` (the closing `)` is excluded), so a
715/// span-based splice leaves trailing bytes behind and corrupts the output
716/// (#57). Matching over tokens is exact because the tokenizer lexes string
717/// literals and comments into single tokens, so parentheses inside them are
718/// never counted. The marker name position (`name_start`) is itself reliable;
719/// between it and the call's `(` there is only trivia (skipped here).
720fn marker_call_close(tokens: &[TokenWithSpan], name_start: Location) -> Option<Location> {
721 let mut depth = 0usize;
722 let mut opened = false;
723 for t in tokens.iter().filter(|t| t.span.start >= name_start) {
724 match t.token {
725 Token::LParen => {
726 depth += 1;
727 opened = true;
728 }
729 Token::RParen => {
730 depth = depth.checked_sub(1)?;
731 if opened && depth == 0 {
732 return Some(t.span.start);
733 }
734 }
735 _ => {}
736 }
737 }
738 None
739}
740
741// ---------------------------------------------------------------------------
742// Span (1-based character line/column) → byte offset conversion (G3)
743// ---------------------------------------------------------------------------
744
745/// `sqlparser` uses `line: 0`/`column: 0` for an empty/unknown location.
746fn is_empty_span(span: &Span) -> bool {
747 span.start.line == 0 || span.start.column == 0 || span.end.line == 0 || span.end.column == 0
748}
749
750/// Convert a 1-based (line, character-column) [`Location`] to a byte offset in
751/// `sql`. Character-column, not byte-column: a multibyte character before the
752/// target shifts the byte offset past the column number (G3).
753///
754/// With `past = false` the returned offset is the *start* byte of the character
755/// at `loc`; with `past = true` it is the byte *one past* that character — used
756/// for an inclusive span end, so the whole marker (its closing `)` included) is
757/// covered by `start..end`.
758fn locate(sql: &str, loc: Location, past: bool) -> Option<usize> {
759 let mut line: u64 = 1;
760 let mut col: u64 = 1;
761 for (byte_idx, ch) in sql.char_indices() {
762 if line == loc.line && col == loc.column {
763 return Some(if past {
764 byte_idx + ch.len_utf8()
765 } else {
766 byte_idx
767 });
768 }
769 if ch == '\n' {
770 line += 1;
771 col = 1;
772 } else {
773 col += 1;
774 }
775 }
776 // A location just past the final character maps to the end of the string.
777 if line == loc.line && col == loc.column {
778 return Some(sql.len());
779 }
780 None
781}
782
783#[cfg(test)]
784mod tests {
785 use super::*;
786
787 #[test]
788 fn pre_gate_matches_unqualified_markers() {
789 assert!(pre_gate_hit("SELECT grad(x, x) FROM t"));
790 assert!(pre_gate_hit("SELECT jvp(x, x, dx) FROM t"));
791 assert!(pre_gate_hit("grad(x,x)")); // at start of input
792 assert!(pre_gate_hit("SELECT GRAD (x, x) FROM t")); // case + whitespace
793 assert!(pre_gate_hit("SELECT AVG(grad(x, x)) FROM t")); // after `(`
794 }
795
796 #[test]
797 fn pre_gate_rejects_non_markers() {
798 assert!(!pre_gate_hit("SELECT a + b FROM t")); // no marker
799 assert!(!pre_gate_hit("SELECT mygrad(x) FROM t")); // longer identifier
800 assert!(!pre_gate_hit("SELECT schema.grad(x, x) FROM t")); // qualified
801 assert!(!pre_gate_hit("SELECT grad AS g FROM t")); // no open paren
802 assert!(!pre_gate_hit("SELECT upgrade(x) FROM t")); // 'grad' inside a word
803 }
804}