Skip to main content

mz_deploy/project/syntax/
variables.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! psql-style variable resolution for SQL files.
11//!
12//! Resolves `:foo`, `:'foo'`, and `:"foo"` syntax in raw SQL text before
13//! it reaches the SQL parser. Variables are defined per-profile in
14//! `[<profile>.variables]` in `project.toml`.
15//!
16//! ## Variable Syntax
17//!
18//! | Syntax | Semantics | Example output |
19//! |--------|-----------|----------------|
20//! | `:name` | Raw substitution — value inserted verbatim | `analytics` |
21//! | `:'name'` | SQL literal — value wrapped in single quotes, `'` doubled | `'it''s-a-host'` |
22//! | `:"name"` | SQL identifier — value wrapped in double quotes, `"` doubled | `"my""col"` |
23//!
24//! ## Resolution Rules
25//!
26//! 1. Variables are looked up in the `BTreeMap<String, String>` passed to
27//!    [`resolve_variables`]. This map is populated from `project.toml`'s
28//!    `[<profile>.variables]` section.
29//! 2. If a referenced variable has no definition, it is left as-is in the
30//!    output and an [`UnresolvedVariable`] (with byte offset and length) is
31//!    recorded in `ResolvedSql::unresolved`. Each occurrence is tracked
32//!    separately (not deduplicated) so the LSP can highlight every reference.
33//! 3. Every resolved substitution is recorded as a [`Substitution`] in
34//!    `ResolvedSql::substitutions`, enabling [`resolved_to_original`] to map
35//!    byte offsets in the resolved text back to the original source positions.
36//! 4. The caller decides whether unresolved variables are errors or warnings
37//!    based on the `PRAGMA WARN_ON_MISSING_VARIABLES;` directive.
38//!
39//! ## Context Awareness
40//!
41//! Variable references are **not** resolved inside:
42//! - Single-quoted string literals (`'...'`)
43//! - Double-quoted identifiers (`"..."`)
44//! - Line comments (`-- ...`)
45//! - Block comments (`/* ... */`), including nested blocks
46//! - Dollar-quoted strings (`$$...$$` or `$tag$...$tag$`)
47//!
48//! The `::` token (PostgreSQL type cast) is never interpreted as a variable
49//! reference.
50
51use std::borrow::Cow;
52use std::collections::BTreeMap;
53use std::path::PathBuf;
54
55/// An unresolved variable reference with its location in the original SQL.
56#[derive(Debug, Clone)]
57pub struct UnresolvedVariable {
58    /// The variable name (without the leading `:` or surrounding quotes).
59    pub name: String,
60    /// Byte offset of the `:` in the original SQL.
61    pub byte_offset: usize,
62    /// Byte length of the full reference (`:foo` = 4, `:'foo'` = 6).
63    pub byte_len: usize,
64}
65
66/// A resolved variable substitution, for mapping offsets between original and resolved text.
67#[derive(Debug, Clone)]
68pub(crate) struct Substitution {
69    /// Byte offset of the `:` in the original SQL.
70    pub original_start: usize,
71    /// Byte length of the variable reference in the original SQL.
72    pub original_len: usize,
73    /// Byte length of the replacement in the resolved SQL.
74    pub resolved_len: usize,
75}
76
77/// Error returned when SQL contains variable references that have no definition.
78#[derive(Debug)]
79pub struct VariableError {
80    pub unresolved: Vec<UnresolvedVariable>,
81    pub path: PathBuf,
82    /// Whether a profile was active when these variables failed to resolve.
83    /// `false` means no `--profile`, `MZ_DEPLOY_PROFILE`, or `.mzprofile`
84    /// was set; the user should be directed to set one.
85    pub profile_set: bool,
86}
87
88/// Result of resolving psql-style variables in SQL text.
89#[derive(Debug)]
90pub(crate) struct ResolvedSql<'a> {
91    /// The SQL text with resolved variables (unresolved ones left as-is).
92    pub sql: Cow<'a, str>,
93    /// Variable references that had no definition, with their positions.
94    pub unresolved: Vec<UnresolvedVariable>,
95    /// Substitutions performed, for mapping offsets between resolved and original text.
96    pub substitutions: Vec<Substitution>,
97    /// Whether the file contains a `PRAGMA WARN_ON_MISSING_VARIABLES;` directive.
98    pub has_warn_pragma: bool,
99}
100
101/// Map a byte offset in resolved text back to the corresponding offset in original text.
102///
103/// Walks `substitutions` in order, tracking the cumulative delta between original
104/// and resolved positions. If `offset` falls before the next substitution in resolved
105/// space, applies the running delta. If inside a substitution's resolved span, clamps
106/// to that substitution's original start. Otherwise continues accumulating delta.
107#[allow(clippy::as_conversions)]
108pub(crate) fn resolved_to_original(offset: usize, substitutions: &[Substitution]) -> usize {
109    let mut delta: isize = 0; // original - resolved cumulative shift
110
111    for sub in substitutions {
112        // Where this substitution starts in resolved-text space.
113        let resolved_start = (sub.original_start as isize - delta) as usize;
114
115        if offset < resolved_start {
116            // Before this substitution — apply accumulated delta.
117            return (offset as isize + delta) as usize;
118        }
119
120        let resolved_end = resolved_start + sub.resolved_len;
121        if offset < resolved_end {
122            // Inside this substitution's resolved span — clamp to original start.
123            return sub.original_start;
124        }
125
126        // Past this substitution — accumulate delta.
127        delta += sub.original_len as isize - sub.resolved_len as isize;
128    }
129
130    // After all substitutions.
131    (offset as isize + delta) as usize
132}
133
134const PRAGMA: &str = "PRAGMA WARN_ON_MISSING_VARIABLES;";
135
136/// Check whether the first non-whitespace content in `sql` is a comment
137/// containing the warn-on-missing-variables pragma.
138fn detect_warn_pragma(sql: &str) -> bool {
139    let trimmed = sql.trim_start();
140
141    if let Some(rest) = trimmed.strip_prefix("--") {
142        // Line comment: check the text up to the first newline.
143        let line = match rest.find('\n') {
144            Some(pos) => &rest[..pos],
145            None => rest,
146        };
147        line.contains(PRAGMA)
148    } else if let Some(rest) = trimmed.strip_prefix("/*") {
149        // Block comment: check the text up to the closing `*/`.
150        match rest.find("*/") {
151            Some(pos) => rest[..pos].contains(PRAGMA),
152            None => rest.contains(PRAGMA),
153        }
154    } else {
155        false
156    }
157}
158
159/// The kind of variable reference found in the SQL text.
160enum VarKind {
161    /// `:name` — substitute raw value
162    Raw,
163    /// `:'name'` — wrap in single quotes with escaping
164    SqlLiteral,
165    /// `:"name"` — wrap in double quotes with escaping
166    SqlIdentifier,
167}
168
169/// Check if `bytes[i..]` starts with `needle`.
170fn starts_with(bytes: &[u8], i: usize, needle: &[u8]) -> bool {
171    bytes[i..].starts_with(needle)
172}
173
174/// Push `value` into `out`, doubling any occurrence of `quote`.
175fn push_sql_escaped(out: &mut String, value: &str, quote: char) {
176    for ch in value.chars() {
177        if ch == quote {
178            out.push(ch);
179        }
180        out.push(ch);
181    }
182}
183
184/// Try to read a variable reference starting at position `i` (which must be `:`).
185///
186/// Returns `(name, kind, end_position)` or `None` if this isn't a variable.
187fn try_read_variable<'a>(
188    sql: &'a str,
189    bytes: &[u8],
190    i: usize,
191) -> Option<(&'a str, VarKind, usize)> {
192    let len = bytes.len();
193    if i + 1 >= len {
194        return None;
195    }
196
197    // :: is a type cast
198    if bytes[i + 1] == b':' {
199        return None;
200    }
201
202    // :'name'
203    if bytes[i + 1] == b'\'' {
204        let name_start = i + 2;
205        let mut j = name_start;
206        while j < len && bytes[j] != b'\'' {
207            j += 1;
208        }
209        if j >= len {
210            return None; // unterminated
211        }
212        let name = &sql[name_start..j];
213        return Some((name, VarKind::SqlLiteral, j + 1));
214    }
215
216    // :"name"
217    if bytes[i + 1] == b'"' {
218        let name_start = i + 2;
219        let mut j = name_start;
220        while j < len && bytes[j] != b'"' {
221            j += 1;
222        }
223        if j >= len {
224            return None; // unterminated
225        }
226        let name = &sql[name_start..j];
227        return Some((name, VarKind::SqlIdentifier, j + 1));
228    }
229
230    // :name (bare identifier)
231    if bytes[i + 1].is_ascii_alphabetic() || bytes[i + 1] == b'_' {
232        let name_start = i + 1;
233        let mut j = name_start;
234        while j < len && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
235            j += 1;
236        }
237        let name = &sql[name_start..j];
238        return Some((name, VarKind::Raw, j));
239    }
240
241    None
242}
243
244/// Consume a single-quoted string. `i` is the position after the opening `'`.
245/// Returns the position after the closing `'`.
246fn consume_single_quoted(bytes: &[u8], mut i: usize, len: usize) -> usize {
247    while i < len {
248        if bytes[i] == b'\'' {
249            if i + 1 < len && bytes[i + 1] == b'\'' {
250                i += 2; // escaped quote
251            } else {
252                return i + 1; // closing quote
253            }
254        } else {
255            i += 1;
256        }
257    }
258    i
259}
260
261/// Consume a double-quoted identifier. `i` is the position after the opening `"`.
262/// Returns the position after the closing `"`.
263fn consume_double_quoted(bytes: &[u8], mut i: usize, len: usize) -> usize {
264    while i < len {
265        if bytes[i] == b'"' {
266            return i + 1;
267        }
268        i += 1;
269    }
270    i
271}
272
273/// Consume a line comment. `i` is the position after `--`.
274/// Returns the position after `\n` (or end of input).
275fn consume_line_comment(bytes: &[u8], mut i: usize, len: usize) -> usize {
276    while i < len {
277        if bytes[i] == b'\n' {
278            return i + 1;
279        }
280        i += 1;
281    }
282    i
283}
284
285/// Consume a block comment (with nesting). `i` is the position after `/*`.
286/// Returns the position after the final `*/`.
287fn consume_block_comment(bytes: &[u8], mut i: usize, len: usize) -> usize {
288    let mut depth: u32 = 1;
289    while i < len && depth > 0 {
290        if starts_with(bytes, i, b"/*") {
291            depth += 1;
292            i += 2;
293        } else if starts_with(bytes, i, b"*/") {
294            depth -= 1;
295            i += 2;
296        } else {
297            i += 1;
298        }
299    }
300    i
301}
302
303/// Check if `$` at position `i` starts a dollar-quote tag.
304/// Returns `(end_pos, tag_bytes)` where `end_pos` is the position after the closing `$` of the tag.
305fn try_dollar_tag<'a>(bytes: &'a [u8], i: usize, len: usize) -> Option<(usize, &'a [u8])> {
306    // Must start with $
307    if bytes[i] != b'$' {
308        return None;
309    }
310
311    // $$ case: tag is empty
312    if i + 1 < len && bytes[i + 1] == b'$' {
313        return Some((i + 2, &bytes[i..i + 2]));
314    }
315
316    // $tag$ case: tag is [a-zA-Z_][a-zA-Z0-9_]*
317    let tag_start = i + 1;
318    if tag_start < len && (bytes[tag_start].is_ascii_alphabetic() || bytes[tag_start] == b'_') {
319        let mut j = tag_start + 1;
320        while j < len && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
321            j += 1;
322        }
323        if j < len && bytes[j] == b'$' {
324            return Some((j + 1, &bytes[i..j + 1]));
325        }
326    }
327
328    None
329}
330
331/// Consume a dollar-quoted string. `i` is the position after the opening tag.
332/// Scans for the matching closing tag.
333fn consume_dollar_quoted(bytes: &[u8], mut i: usize, len: usize, tag: &[u8]) -> usize {
334    while i < len {
335        if bytes[i] == b'$' && bytes[i..].starts_with(tag) {
336            return i + tag.len();
337        }
338        i += 1;
339    }
340    i
341}
342
343/// Find the variable reference (if any) that contains the given byte offset.
344///
345/// Scans `sql` using the same context-awareness rules as [`resolve_variables`]
346/// (skipping strings, comments, dollar-quotes, type casts) and checks whether
347/// `offset` falls inside a variable reference.
348///
349/// # Returns
350/// `(name, byte_offset_of_colon, byte_len)` if the offset is inside a
351/// `:name`, `:'name'`, or `:"name"` reference outside of strings/comments.
352/// `None` otherwise.
353pub(crate) fn find_variable_at_position(
354    sql: &str,
355    offset: usize,
356) -> Option<(String, usize, usize)> {
357    let bytes = sql.as_bytes();
358    let len = bytes.len();
359    let mut i = 0;
360
361    while i < len {
362        if bytes[i] == b'\'' {
363            i = consume_single_quoted(bytes, i + 1, len);
364        } else if bytes[i] == b'"' {
365            i = consume_double_quoted(bytes, i + 1, len);
366        } else if starts_with(bytes, i, b"--") {
367            i = consume_line_comment(bytes, i + 2, len);
368        } else if starts_with(bytes, i, b"/*") {
369            i = consume_block_comment(bytes, i + 2, len);
370        } else if bytes[i] == b'$' {
371            if let Some((end, tag)) = try_dollar_tag(bytes, i, len) {
372                i = consume_dollar_quoted(bytes, end, len, tag);
373            } else {
374                i += 1;
375            }
376        } else if starts_with(bytes, i, b"::") {
377            i += 2;
378        } else if bytes[i] == b':' {
379            if let Some((name, _kind, end)) = try_read_variable(sql, bytes, i) {
380                let var_start = i;
381                let var_len = end - var_start;
382                if offset >= var_start && offset < end {
383                    return Some((name.to_string(), var_start, var_len));
384                }
385                i = end;
386            } else {
387                i += 1;
388            }
389        } else {
390            i += 1;
391        }
392    }
393
394    None
395}
396
397/// Resolve psql-style variables (`:foo`, `:'foo'`, `:"foo"`) in SQL text.
398///
399/// Always returns `ResolvedSql` with the SQL text (unresolved variables left as-is),
400/// a list of unresolved variable names, and whether the pragma was detected.
401/// The caller decides whether unresolved variables are errors or warnings.
402pub(crate) fn resolve_variables<'a>(
403    sql: &'a str,
404    vars: &BTreeMap<String, String>,
405) -> ResolvedSql<'a> {
406    let bytes = sql.as_bytes();
407    let len = bytes.len();
408
409    if len == 0 {
410        return ResolvedSql {
411            sql: Cow::Borrowed(sql),
412            unresolved: Vec::new(),
413            substitutions: Vec::new(),
414            has_warn_pragma: false,
415        };
416    }
417
418    let has_warn_pragma = detect_warn_pragma(sql);
419
420    let mut i = 0;
421    let mut output: Option<String> = None;
422    let mut copy_from: usize = 0;
423    let mut unresolved: Vec<UnresolvedVariable> = Vec::new();
424    let mut substitutions: Vec<Substitution> = Vec::new();
425
426    while i < len {
427        if bytes[i] == b'\'' {
428            i = consume_single_quoted(bytes, i + 1, len);
429        } else if bytes[i] == b'"' {
430            i = consume_double_quoted(bytes, i + 1, len);
431        } else if starts_with(bytes, i, b"--") {
432            i = consume_line_comment(bytes, i + 2, len);
433        } else if starts_with(bytes, i, b"/*") {
434            i = consume_block_comment(bytes, i + 2, len);
435        } else if bytes[i] == b'$' {
436            if let Some((end, tag)) = try_dollar_tag(bytes, i, len) {
437                i = consume_dollar_quoted(bytes, end, len, tag);
438            } else {
439                i += 1;
440            }
441        } else if starts_with(bytes, i, b"::") {
442            // Type cast — skip both colons
443            i += 2;
444        } else if bytes[i] == b':' {
445            if let Some((name, kind, end)) = try_read_variable(sql, bytes, i) {
446                // Flush pending text and perform substitution
447                let var_start = i;
448                let buf = match output {
449                    Some(ref mut buf) => {
450                        buf.push_str(&sql[copy_from..var_start]);
451                        buf
452                    }
453                    None => {
454                        let mut buf = String::with_capacity(sql.len());
455                        buf.push_str(&sql[copy_from..var_start]);
456                        output = Some(buf);
457                        output.as_mut().unwrap()
458                    }
459                };
460
461                if let Some(value) = vars.get(name) {
462                    let before = buf.len();
463                    match kind {
464                        VarKind::Raw => buf.push_str(value),
465                        VarKind::SqlLiteral => {
466                            buf.push('\'');
467                            push_sql_escaped(buf, value, '\'');
468                            buf.push('\'');
469                        }
470                        VarKind::SqlIdentifier => {
471                            buf.push('"');
472                            push_sql_escaped(buf, value, '"');
473                            buf.push('"');
474                        }
475                    }
476                    let after = buf.len();
477                    substitutions.push(Substitution {
478                        original_start: var_start,
479                        original_len: end - var_start,
480                        resolved_len: after - before,
481                    });
482                } else {
483                    unresolved.push(UnresolvedVariable {
484                        name: name.to_string(),
485                        byte_offset: var_start,
486                        byte_len: end - var_start,
487                    });
488                    buf.push_str(&sql[var_start..end]);
489                }
490
491                copy_from = end;
492                i = end;
493            } else {
494                i += 1;
495            }
496        } else {
497            i += 1;
498        }
499    }
500
501    let sql = match output {
502        Some(mut buf) => {
503            buf.push_str(&sql[copy_from..]);
504            Cow::Owned(buf)
505        }
506        None => Cow::Borrowed(sql),
507    };
508
509    ResolvedSql {
510        sql,
511        unresolved,
512        substitutions,
513        has_warn_pragma,
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    fn vars(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
522        pairs
523            .iter()
524            .map(|(k, v)| (k.to_string(), v.to_string()))
525            .collect()
526    }
527
528    /// Extract just the names from unresolved variables for easy assertion.
529    fn unresolved_names<'a>(result: &'a ResolvedSql<'a>) -> Vec<&'a str> {
530        result.unresolved.iter().map(|v| v.name.as_str()).collect()
531    }
532
533    #[mz_ore::test]
534    fn no_variables_returns_borrowed() {
535        let sql = "SELECT 1 FROM t WHERE x = 'hello'";
536        let result = resolve_variables(sql, &BTreeMap::new());
537        assert!(matches!(result.sql, Cow::Borrowed(_)));
538        assert_eq!(result.sql.as_ref(), sql);
539        assert!(result.unresolved.is_empty());
540    }
541
542    #[mz_ore::test]
543    fn bare_variable_substitution() {
544        let v = vars(&[("cluster", "analytics")]);
545        let result = resolve_variables(
546            "CREATE MATERIALIZED VIEW mv IN CLUSTER :cluster AS SELECT 1",
547            &v,
548        );
549        assert_eq!(
550            result.sql.as_ref(),
551            "CREATE MATERIALIZED VIEW mv IN CLUSTER analytics AS SELECT 1"
552        );
553        assert!(result.unresolved.is_empty());
554    }
555
556    #[mz_ore::test]
557    fn single_quoted_variable_with_escaping() {
558        let v = vars(&[("pg_host", "it's-a-host")]);
559        let result = resolve_variables("CREATE CONNECTION pg TO POSTGRES (HOST :'pg_host')", &v);
560        assert_eq!(
561            result.sql.as_ref(),
562            "CREATE CONNECTION pg TO POSTGRES (HOST 'it''s-a-host')"
563        );
564        assert!(result.unresolved.is_empty());
565    }
566
567    #[mz_ore::test]
568    fn double_quoted_variable_with_escaping() {
569        let v = vars(&[("col", "my\"col")]);
570        let result = resolve_variables("SELECT :\"col\" FROM t", &v);
571        assert_eq!(result.sql.as_ref(), "SELECT \"my\"\"col\" FROM t");
572        assert!(result.unresolved.is_empty());
573    }
574
575    #[mz_ore::test]
576    fn type_cast_preserved() {
577        let result = resolve_variables("SELECT x::int FROM t", &BTreeMap::new());
578        assert!(matches!(result.sql, Cow::Borrowed(_)));
579        assert_eq!(result.sql.as_ref(), "SELECT x::int FROM t");
580        assert!(result.unresolved.is_empty());
581    }
582
583    #[mz_ore::test]
584    fn variable_inside_string_literal_not_resolved() {
585        let v = vars(&[("foo", "bar")]);
586        let result = resolve_variables("SELECT ':foo' FROM t", &v);
587        assert!(matches!(result.sql, Cow::Borrowed(_)));
588        assert_eq!(result.sql.as_ref(), "SELECT ':foo' FROM t");
589    }
590
591    #[mz_ore::test]
592    fn variable_in_line_comment_not_resolved() {
593        let v = vars(&[("foo", "bar")]);
594        let result = resolve_variables("-- :foo\nSELECT 1", &v);
595        assert!(matches!(result.sql, Cow::Borrowed(_)));
596        assert_eq!(result.sql.as_ref(), "-- :foo\nSELECT 1");
597    }
598
599    #[mz_ore::test]
600    fn variable_in_block_comment_not_resolved() {
601        let v = vars(&[("foo", "bar")]);
602        let result = resolve_variables("/* :foo */ SELECT 1", &v);
603        assert!(matches!(result.sql, Cow::Borrowed(_)));
604        assert_eq!(result.sql.as_ref(), "/* :foo */ SELECT 1");
605    }
606
607    #[mz_ore::test]
608    fn nested_block_comment_not_resolved() {
609        let v = vars(&[("foo", "bar")]);
610        let result = resolve_variables("/* /* :foo */ */ SELECT 1", &v);
611        assert!(matches!(result.sql, Cow::Borrowed(_)));
612        assert_eq!(result.sql.as_ref(), "/* /* :foo */ */ SELECT 1");
613    }
614
615    #[mz_ore::test]
616    fn multiple_variables() {
617        let v = vars(&[("a", "1"), ("b", "2")]);
618        let result = resolve_variables("SELECT :a, :b", &v);
619        assert_eq!(result.sql.as_ref(), "SELECT 1, 2");
620        assert!(result.unresolved.is_empty());
621    }
622
623    #[mz_ore::test]
624    fn unresolved_variable_reported() {
625        let result = resolve_variables("SELECT :missing", &BTreeMap::new());
626        assert_eq!(unresolved_names(&result), vec!["missing"]);
627        assert_eq!(result.sql.as_ref(), "SELECT :missing");
628    }
629
630    #[mz_ore::test]
631    fn multiple_unresolved_lists_all() {
632        let result = resolve_variables("SELECT :a, :b, :a", &BTreeMap::new());
633        assert_eq!(unresolved_names(&result), vec!["a", "b", "a"]);
634    }
635
636    #[mz_ore::test]
637    fn empty_vars_no_syntax_borrowed() {
638        let result = resolve_variables("SELECT 1", &BTreeMap::new());
639        assert!(matches!(result.sql, Cow::Borrowed(_)));
640    }
641
642    #[mz_ore::test]
643    fn empty_vars_with_syntax_reports_unresolved() {
644        let result = resolve_variables("SELECT :foo", &BTreeMap::new());
645        assert!(!result.unresolved.is_empty());
646    }
647
648    #[mz_ore::test]
649    fn variable_at_end_of_input() {
650        let v = vars(&[("foo", "bar")]);
651        let result = resolve_variables("SELECT :foo", &v);
652        assert_eq!(result.sql.as_ref(), "SELECT bar");
653    }
654
655    #[mz_ore::test]
656    fn adjacent_syntax() {
657        let v = vars(&[("foo", "1"), ("bar", "2")]);
658        let result = resolve_variables("(:foo, :bar)", &v);
659        assert_eq!(result.sql.as_ref(), "(1, 2)");
660    }
661
662    #[mz_ore::test]
663    fn single_quoted_no_escaping_needed() {
664        let v = vars(&[("host", "simple-host")]);
665        let result = resolve_variables("HOST :'host'", &v);
666        assert_eq!(result.sql.as_ref(), "HOST 'simple-host'");
667    }
668
669    #[mz_ore::test]
670    fn double_quoted_no_escaping_needed() {
671        let v = vars(&[("col", "simple_col")]);
672        let result = resolve_variables("SELECT :\"col\"", &v);
673        assert_eq!(result.sql.as_ref(), "SELECT \"simple_col\"");
674    }
675
676    #[mz_ore::test]
677    fn double_quoted_identifier_not_resolved() {
678        let v = vars(&[("id", "bar")]);
679        let result = resolve_variables("SELECT \"user:id\" FROM t", &v);
680        assert!(matches!(result.sql, Cow::Borrowed(_)));
681        assert_eq!(result.sql.as_ref(), "SELECT \"user:id\" FROM t");
682    }
683
684    #[mz_ore::test]
685    fn dollar_quoted_not_resolved() {
686        let v = vars(&[("foo", "bar")]);
687        let result = resolve_variables("SELECT $$:foo$$ FROM t", &v);
688        assert!(matches!(result.sql, Cow::Borrowed(_)));
689        assert_eq!(result.sql.as_ref(), "SELECT $$:foo$$ FROM t");
690    }
691
692    #[mz_ore::test]
693    fn dollar_tagged_not_resolved() {
694        let v = vars(&[("foo", "bar")]);
695        let result = resolve_variables("SELECT $tag$:foo$tag$ FROM t", &v);
696        assert!(matches!(result.sql, Cow::Borrowed(_)));
697        assert_eq!(result.sql.as_ref(), "SELECT $tag$:foo$tag$ FROM t");
698    }
699
700    #[mz_ore::test]
701    fn dollar_sign_alone_no_crash() {
702        let result = resolve_variables("SELECT $ FROM t", &BTreeMap::new());
703        assert!(matches!(result.sql, Cow::Borrowed(_)));
704        assert_eq!(result.sql.as_ref(), "SELECT $ FROM t");
705    }
706
707    // --- Pragma tests ---
708
709    #[mz_ore::test]
710    fn pragma_line_comment() {
711        let result = resolve_variables(
712            "-- PRAGMA WARN_ON_MISSING_VARIABLES;\nSELECT :foo",
713            &BTreeMap::new(),
714        );
715        assert!(result.has_warn_pragma);
716        assert_eq!(unresolved_names(&result), vec!["foo"]);
717    }
718
719    #[mz_ore::test]
720    fn pragma_block_comment() {
721        let result = resolve_variables(
722            "/* PRAGMA WARN_ON_MISSING_VARIABLES; */\nSELECT :foo",
723            &BTreeMap::new(),
724        );
725        assert!(result.has_warn_pragma);
726        assert_eq!(unresolved_names(&result), vec!["foo"]);
727    }
728
729    #[mz_ore::test]
730    fn pragma_with_leading_whitespace() {
731        let result = resolve_variables(
732            "  \t\n  -- PRAGMA WARN_ON_MISSING_VARIABLES;\nSELECT :foo",
733            &BTreeMap::new(),
734        );
735        assert!(result.has_warn_pragma);
736    }
737
738    #[mz_ore::test]
739    fn pragma_not_on_first_comment() {
740        let result = resolve_variables(
741            "SELECT 1;\n-- PRAGMA WARN_ON_MISSING_VARIABLES;\nSELECT :foo",
742            &BTreeMap::new(),
743        );
744        assert!(!result.has_warn_pragma);
745    }
746
747    #[mz_ore::test]
748    fn pragma_missing() {
749        let result = resolve_variables("SELECT :foo", &BTreeMap::new());
750        assert!(!result.has_warn_pragma);
751    }
752
753    #[mz_ore::test]
754    fn pragma_partial_match() {
755        let result = resolve_variables("-- PRAGMA WARN_ON_MISSING\nSELECT :foo", &BTreeMap::new());
756        assert!(!result.has_warn_pragma);
757    }
758
759    // --- Unresolved variable position tests ---
760
761    #[mz_ore::test]
762    fn unresolved_variable_has_correct_offset() {
763        // "SELECT :missing" — `:` is at byte 7
764        let result = resolve_variables("SELECT :missing", &BTreeMap::new());
765        assert_eq!(result.unresolved.len(), 1);
766        assert_eq!(result.unresolved[0].name, "missing");
767        assert_eq!(result.unresolved[0].byte_offset, 7);
768        assert_eq!(result.unresolved[0].byte_len, 8); // `:missing` = 8 bytes
769    }
770
771    #[mz_ore::test]
772    fn unresolved_quoted_variable_has_correct_len() {
773        // "SELECT :'missing'" — `:` at 7, len = 10 (:'missing')
774        let result = resolve_variables("SELECT :'missing'", &BTreeMap::new());
775        assert_eq!(result.unresolved.len(), 1);
776        assert_eq!(result.unresolved[0].byte_offset, 7);
777        assert_eq!(result.unresolved[0].byte_len, 10);
778    }
779
780    #[mz_ore::test]
781    fn multiple_unresolved_tracks_each_occurrence() {
782        let result = resolve_variables("SELECT :a, :b, :a", &BTreeMap::new());
783        assert_eq!(result.unresolved.len(), 3);
784        assert_eq!(result.unresolved[0].name, "a");
785        assert_eq!(result.unresolved[0].byte_offset, 7);
786        assert_eq!(result.unresolved[1].name, "b");
787        assert_eq!(result.unresolved[1].byte_offset, 11);
788        assert_eq!(result.unresolved[2].name, "a");
789        assert_eq!(result.unresolved[2].byte_offset, 15);
790    }
791
792    // --- find_variable_at_position tests ---
793
794    #[mz_ore::test]
795    fn find_var_bare_variable() {
796        // "SELECT :foo FROM t" — `:foo` at bytes 7..11
797        let sql = "SELECT :foo FROM t";
798        let result = find_variable_at_position(sql, 7);
799        assert_eq!(result, Some(("foo".to_string(), 7, 4)));
800        // Middle of variable
801        assert_eq!(
802            find_variable_at_position(sql, 9),
803            Some(("foo".to_string(), 7, 4))
804        );
805    }
806
807    #[mz_ore::test]
808    fn find_var_single_quoted() {
809        // "HOST :'host'" — `:'host'` at bytes 5..13
810        let sql = "HOST :'host'";
811        let result = find_variable_at_position(sql, 5);
812        assert_eq!(result, Some(("host".to_string(), 5, 7)));
813        assert_eq!(
814            find_variable_at_position(sql, 11),
815            Some(("host".to_string(), 5, 7))
816        );
817    }
818
819    #[mz_ore::test]
820    fn find_var_double_quoted() {
821        // "SELECT :\"col\"" — `:"col"` at bytes 7..13
822        let sql = "SELECT :\"col\"";
823        let result = find_variable_at_position(sql, 7);
824        assert_eq!(result, Some(("col".to_string(), 7, 6)));
825    }
826
827    #[mz_ore::test]
828    fn find_var_between_variables_returns_none() {
829        // "SELECT :a, :b" — space at byte 10 is between variables
830        let sql = "SELECT :a, :b";
831        assert_eq!(find_variable_at_position(sql, 10), None);
832    }
833
834    #[mz_ore::test]
835    fn find_var_in_string_literal_returns_none() {
836        let sql = "SELECT ':foo' FROM t";
837        // Byte 8 is inside the string literal
838        assert_eq!(find_variable_at_position(sql, 8), None);
839    }
840
841    #[mz_ore::test]
842    fn find_var_type_cast_returns_none() {
843        let sql = "SELECT x::int FROM t";
844        // Byte 9 is the second colon of ::
845        assert_eq!(find_variable_at_position(sql, 9), None);
846        assert_eq!(find_variable_at_position(sql, 8), None);
847    }
848
849    #[mz_ore::test]
850    fn find_var_in_comment_returns_none() {
851        let sql = "-- :foo\nSELECT 1";
852        assert_eq!(find_variable_at_position(sql, 3), None);
853    }
854
855    #[mz_ore::test]
856    fn find_var_offset_past_end_returns_none() {
857        let sql = "SELECT :foo";
858        assert_eq!(find_variable_at_position(sql, 11), None);
859    }
860
861    // --- Substitution tracking tests ---
862
863    #[mz_ore::test]
864    fn resolved_variable_records_substitution() {
865        let v = vars(&[("cluster", "analytics")]);
866        let result = resolve_variables("IN CLUSTER :cluster AS", &v);
867        assert_eq!(result.substitutions.len(), 1);
868        assert_eq!(result.substitutions[0].original_start, 11); // `:cluster` starts at 11
869        assert_eq!(result.substitutions[0].original_len, 8); // `:cluster` = 8 bytes
870        assert_eq!(result.substitutions[0].resolved_len, 9); // `analytics` = 9 bytes
871    }
872
873    // --- resolved_to_original tests ---
874
875    #[mz_ore::test]
876    fn resolved_to_original_no_substitutions() {
877        assert_eq!(resolved_to_original(5, &[]), 5);
878        assert_eq!(resolved_to_original(0, &[]), 0);
879    }
880
881    #[mz_ore::test]
882    fn resolved_to_original_shorter_replacement() {
883        // Original: "IN CLUSTER :cluster AS" (22 bytes)
884        //                        ^11     ^19
885        // Resolved: "IN CLUSTER ab AS" (16 bytes)  — `:cluster` (8) → `ab` (2)
886        //                        ^11 ^13
887        let subs = vec![Substitution {
888            original_start: 11,
889            original_len: 8,
890            resolved_len: 2,
891        }];
892        // Before substitution
893        assert_eq!(resolved_to_original(5, &subs), 5);
894        // Inside substitution (resolved offset 11 or 12) → clamp to original start
895        assert_eq!(resolved_to_original(11, &subs), 11);
896        assert_eq!(resolved_to_original(12, &subs), 11);
897        // After substitution: resolved 13 → original 19 (delta = 8 - 2 = 6)
898        assert_eq!(resolved_to_original(13, &subs), 19);
899    }
900
901    #[mz_ore::test]
902    fn resolved_to_original_longer_replacement() {
903        // Original: "X :a Y" — `:a` at 2, len 2
904        // Resolved: "X longvalue Y" — `longvalue` len 9
905        let subs = vec![Substitution {
906            original_start: 2,
907            original_len: 2,
908            resolved_len: 9,
909        }];
910        // Before
911        assert_eq!(resolved_to_original(0, &subs), 0);
912        // Inside (2..11) → clamp to 2
913        assert_eq!(resolved_to_original(5, &subs), 2);
914        // After: resolved 11 → original 4 (delta = 2 - 9 = -7)
915        assert_eq!(resolved_to_original(11, &subs), 4);
916    }
917
918    #[mz_ore::test]
919    fn resolved_to_original_multiple_substitutions() {
920        // Original: "A :x B :y C" — :x at 2 (len 2), :y at 7 (len 2)
921        // Resolved: "A val1 B val2 C" — val1 (len 4), val2 (len 4)
922        let subs = vec![
923            Substitution {
924                original_start: 2,
925                original_len: 2,
926                resolved_len: 4,
927            },
928            Substitution {
929                original_start: 7,
930                original_len: 2,
931                resolved_len: 4,
932            },
933        ];
934        // Before first sub
935        assert_eq!(resolved_to_original(0, &subs), 0);
936        // Inside first sub (resolved 2..6)
937        assert_eq!(resolved_to_original(3, &subs), 2);
938        // Between subs: resolved 6 → original 4, delta = 2 - 4 = -2
939        assert_eq!(resolved_to_original(6, &subs), 4);
940        // Inside second sub: resolved 9 (second sub starts at resolved 7 + delta(-2) → 9)
941        // Actually: second sub original_start=7, delta after first = 2-4 = -2
942        // resolved_start = 7 - (-2) = 9, resolved_end = 9 + 4 = 13
943        assert_eq!(resolved_to_original(10, &subs), 7);
944        // After both: resolved 13 → original 9, cumulative delta = (2-4) + (2-4) = -4
945        assert_eq!(resolved_to_original(13, &subs), 9);
946    }
947}