Skip to main content

libdd_trace_obfuscation/
sql.rs

1// Copyright 2023-Present Datadog, Inc. https://www.datadoghq.com/
2// SPDX-License-Identifier: Apache-2.0
3
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8#[non_exhaustive]
9pub enum DbmsKind {
10    #[default]
11    Generic,
12    Mssql,
13    Mysql,
14    Postgresql,
15    Oracle,
16}
17
18/// See `DbmsKind` for the list of supported DBMS.
19pub struct UnknownDBMSError;
20
21impl TryFrom<&str> for DbmsKind {
22    type Error = UnknownDBMSError;
23
24    fn try_from(value: &str) -> Result<Self, Self::Error> {
25        let res = match value.to_lowercase().as_str() {
26            "" => Self::Generic,
27            "mssql" => Self::Mssql,
28            "mysql" => Self::Mysql,
29            "postgresql" => Self::Postgresql,
30            "oracle" => Self::Oracle,
31            _ => return Err(UnknownDBMSError),
32        };
33        Ok(res)
34    }
35}
36
37#[allow(deprecated)]
38#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq)]
39#[serde(rename_all = "snake_case")]
40#[non_exhaustive]
41pub enum SqlObfuscationMode {
42    #[default]
43    #[deprecated = "kept for compatibility with agent's obfuscator but has unintuitive behavior"]
44    #[serde(alias = "")]
45    Unspecified,
46    NormalizeOnly,
47    ObfuscateOnly,
48    ObfuscateAndNormalize,
49}
50
51/// Configuration for SQL obfuscation
52#[derive(Debug, Default, Clone, Deserialize)]
53#[allow(
54    clippy::struct_excessive_bools,
55    reason = "public config schema, should not be refactored"
56)]
57pub struct SqlObfuscateConfig {
58    pub replace_digits: bool,
59    pub keep_sql_alias: bool,
60    pub dollar_quoted_func: bool,
61    pub keep_null: bool,
62    pub keep_boolean: bool,
63    pub keep_positional_parameter: bool,
64    pub keep_trailing_semicolon: bool,
65    pub keep_identifier_quotation: bool,
66    pub replace_bind_parameter: bool,
67    pub remove_space_between_parentheses: bool,
68    pub keep_json_path: bool,
69    pub obfuscation_mode: SqlObfuscationMode,
70}
71
72const fn is_whitespace(b: u8) -> bool {
73    matches!(b, b' ' | b'\t' | b'\n' | b'\r' | 0x0B | 0x0C)
74}
75
76const fn is_ident_start(b: u8) -> bool {
77    b.is_ascii_alphabetic() || b == b'_' || b > 127
78}
79
80const fn is_ident_char(b: u8) -> bool {
81    // Go's scanIdentifier includes '.*$' as continuation chars in addition to alnum/_.
82    // '@' is in Go's isLetter (isLeadingLetter) so it continues identifiers too.
83    // '.' is handled separately (qualifier), but '*', '$', '@' are included here.
84    b.is_ascii_alphanumeric()
85        || b == b'_'
86        || b == b'$'
87        || b == b'#'
88        || b == b'*'
89        || b == b'@'
90        || b > 127
91}
92
93/// Replace trailing digit sequences in identifier with `?`
94/// e.g., `sales_2019_07_01` → sales_?_?_?
95///       item1001 → item?
96///       ddh19 → ddh?
97fn apply_replace_digits(ident: &str) -> String {
98    let bytes = ident.as_bytes();
99    let mut result = String::with_capacity(ident.len());
100    let mut i = 0;
101    while i < bytes.len() {
102        if bytes[i].is_ascii_digit() {
103            while i < bytes.len() && bytes[i].is_ascii_digit() {
104                i += 1;
105            }
106            result.push('?');
107        } else {
108            // Push char as UTF-8
109            let c = ident[i..].chars().next().unwrap_or(' ');
110            result.push(c);
111            i += c.len_utf8();
112        }
113    }
114    result
115}
116
117/// Returns the index just past the closing `'` of a quoted string starting at `start`.
118fn find_quoted_string_end(bytes: &[u8], start: usize) -> Option<usize> {
119    if bytes.get(start) != Some(&b'\'') {
120        return None;
121    }
122    // First: try short close (no backslash escape) — standard SQL uses '' only
123    let short_end = {
124        let mut i = start + 1;
125        let mut result = None;
126        while i < bytes.len() {
127            if bytes[i] == b'\'' {
128                if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
129                    i += 2; // '' escape
130                    continue;
131                } else {
132                    result = Some(i + 1);
133                    break;
134                }
135            }
136            i += 1;
137        }
138        result
139    };
140
141    // Use the short close if what follows is a SQL word boundary (not alphanumeric/underscore),
142    // meaning the string truly ends there. If followed by alphanumeric, the \' was likely
143    // an escape and the string continues — try greedy (with backslash escape).
144    let short_at_boundary = short_end.is_some_and(|end| {
145        !bytes
146            .get(end)
147            .is_some_and(|&c| c.is_ascii_alphanumeric() || c == b'_')
148    });
149
150    if short_at_boundary {
151        return short_end;
152    }
153
154    // Greedy: use backslash escape to find a longer match
155    let mut i = start + 1;
156    let mut escaped = false;
157    while i < bytes.len() {
158        if escaped {
159            escaped = false;
160        } else if bytes[i] == b'\\' {
161            escaped = true;
162        } else if bytes[i] == b'\'' {
163            if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
164                i += 1; // '' escape inside greedy scan
165            } else {
166                return Some(i + 1);
167            }
168        }
169        i += 1;
170    }
171
172    // Greedy found nothing; fall back to short_end (even if not at word boundary)
173    short_end
174}
175
176/// Find the end of a dollar-quoted string $tag$...$tag$
177/// Returns (`inner_start`, `inner_end`, `outer_end`) or None if not a valid dollar quote
178fn find_dollar_quote_end(bytes: &[u8], start: usize) -> Option<(usize, usize, usize)> {
179    let n = bytes.len();
180    if start >= n || bytes[start] != b'$' {
181        return None;
182    }
183    // Collect the tag: $<tag>$ — Go allows spaces and other chars in tags
184    let mut tag_end = start + 1;
185    while tag_end < n && bytes[tag_end] != b'$' {
186        if bytes[tag_end] == b'\n' {
187            return None; // tags don't span lines
188        }
189        tag_end += 1;
190    }
191    if tag_end >= n {
192        return None;
193    }
194    // tag is bytes[start..=tag_end], e.g. $func$ or $$
195    let tag = &bytes[start..=tag_end];
196    let inner_start = tag_end + 1;
197
198    // Search for closing tag
199    let mut i = inner_start;
200    while i + tag.len() <= n {
201        if bytes[i] == b'$' && bytes[i..].starts_with(tag) {
202            return Some((inner_start, i, i + tag.len()));
203        }
204        i += 1;
205    }
206    None
207}
208
209struct Tokenizer<'a> {
210    s: &'a str,
211    bytes: &'a [u8],
212    pos: usize,
213    result: String,
214    dbms: DbmsKind,
215    config: &'a SqlObfuscateConfig,
216    // For alias stripping: length of result before we emitted the most recent ' AS' segment
217    before_as_len: Option<usize>,
218    // After SAVEPOINT keyword, next token should become ?
219    pending_savepoint: bool,
220    // True when the last emitted non-space char was a standalone placeholder '?'
221    // (as opposed to '?' from replace_digits inside an identifier name)
222    last_was_placeholder: bool,
223    // When keep_json_path=true, set after -> or ->> to keep next literal as-is
224    pending_json_path: bool,
225    // True when the last emitted operator was a standalone = (assignment/comparison)
226    // Used to detect value context for double-quoted strings
227    last_was_assign: bool,
228}
229
230impl<'a> Tokenizer<'a> {
231    fn new(s: &'a str, config: &'a SqlObfuscateConfig, dbms: DbmsKind) -> Self {
232        Self {
233            s,
234            bytes: s.as_bytes(),
235            pos: 0,
236            result: String::with_capacity(s.len()),
237            dbms,
238            config,
239            before_as_len: None,
240            pending_savepoint: false,
241            last_was_placeholder: false,
242            pending_json_path: false,
243            last_was_assign: false,
244        }
245    }
246
247    fn peek(&self, offset: usize) -> Option<u8> {
248        self.bytes.get(self.pos + offset).copied()
249    }
250
251    const fn at_end(&self) -> bool {
252        self.pos >= self.bytes.len()
253    }
254
255    const fn is_normalize_only(&self) -> bool {
256        matches!(
257            self.config.obfuscation_mode,
258            SqlObfuscationMode::NormalizeOnly
259        )
260    }
261
262    const fn is_obfuscate_only(&self) -> bool {
263        matches!(
264            self.config.obfuscation_mode,
265            SqlObfuscationMode::ObfuscateOnly
266        )
267    }
268
269    #[allow(deprecated)]
270    const fn is_unspecified_obfuscate_mode(&self) -> bool {
271        matches!(
272            self.config.obfuscation_mode,
273            SqlObfuscationMode::Unspecified
274        )
275    }
276
277    const fn last_char(&self) -> Option<u8> {
278        self.result.as_bytes().last().copied()
279    }
280
281    fn last_nonspace_char(&self) -> Option<u8> {
282        self.result
283            .as_bytes()
284            .iter()
285            .rev()
286            .find(|&&b| b != b' ')
287            .copied()
288    }
289
290    /// Push a space if result doesn't already end with one (and result is non-empty).
291    /// Does NOT add space after `.` (qualifier separator).
292    /// When actually pushing a space, resets `last_was_placeholder` — equivalent to Go's
293    /// `groupingFilter.Reset()` on any non-comma, non-paren, non-FilteredGroupable token.
294    fn space(&mut self) {
295        if !self.result.is_empty()
296            && self.last_char() != Some(b' ')
297            && !(self.last_char() == Some(b'.') && {
298                // Only suppress space after '.' when it acts as a qualifier separator
299                // (preceded by an identifier character like "table.column").
300                // For standalone '.' at the start or after operators, add the space.
301                let len = self.result.len();
302                if len < 2 {
303                    false
304                } else {
305                    let before_dot = self.result.as_bytes()[len - 2];
306                    before_dot.is_ascii_alphanumeric()
307                        || before_dot == b'_'
308                        || before_dot == b'"'
309                        || before_dot == b']'
310                        || before_dot == b'#'
311                        || before_dot == b'?'
312                        || before_dot > 127 // Non-ASCII identifier chars
313                }
314            })
315            && !(self.last_char() == Some(b'(')
316                && (self.config.remove_space_between_parentheses || self.is_obfuscate_only()))
317        {
318            // Reset last_was_placeholder when transitioning past a non-placeholder token
319            // (e.g., operator or identifier). When last non-space char is '?', preserve
320            // the group state so that commas stripped after a literal don't break grouping.
321            if self.last_nonspace_char() != Some(b'?') {
322                self.last_was_placeholder = false;
323            }
324            self.result.push(' ');
325        } else if self.last_char() == Some(b' ')
326            && !matches!(self.last_nonspace_char(), Some(b'?' | b'('))
327        {
328            // Result already ends in space (e.g. after an operator like '!') and we still need
329            // to reset placeholder state. Do NOT reset after '(' — Go's groupingFilter lets
330            // last_was_placeholder persist through '(' tokens.
331            self.last_was_placeholder = false;
332        }
333    }
334
335    /// Emit a token, adding a space before it if needed.
336    fn emit(&mut self, token: &str) {
337        self.space();
338        self.result.push_str(token);
339        self.last_was_placeholder = false;
340        self.last_was_assign = false;
341    }
342
343    /// Emit a single char token, adding a space before if needed.
344    fn emit_char(&mut self, c: char) {
345        self.space();
346        self.result.push(c);
347        self.last_was_placeholder = c == '?';
348        self.last_was_assign = false;
349    }
350
351    /// Emit a literal-replacement '?' with consecutive-duplicate suppression.
352    /// In legacy mode, Go's groupingFilter suppresses consecutive `FilteredGroupable` tokens
353    /// (groupFilter > 1). If `last_was_placeholder` is already true, suppress this one.
354    fn emit_placeholder(&mut self) {
355        if self.is_unspecified_obfuscate_mode() && self.last_was_placeholder {
356            // Suppress consecutive placeholder (Go groupFilter > 1 rule)
357            return;
358        }
359        self.emit_char('?');
360    }
361
362    fn skip_whitespace(&mut self) {
363        while !self.at_end() && is_whitespace(self.bytes[self.pos]) {
364            self.pos += 1;
365        }
366        // Also skip Unicode whitespace (e.g. U+2003 EM SPACE) — Go uses unicode.IsSpace
367        while !self.at_end() && self.bytes[self.pos] > 127 {
368            if let Some(c) = self.s[self.pos..].chars().next() {
369                if c.is_whitespace() {
370                    self.pos += c.len_utf8();
371                    // There may be ASCII whitespace after, loop again
372                    while !self.at_end() && is_whitespace(self.bytes[self.pos]) {
373                        self.pos += 1;
374                    }
375                    continue;
376                }
377            }
378            break;
379        }
380    }
381
382    fn skip_line_comment(&mut self) {
383        while !self.at_end() && self.bytes[self.pos] != b'\n' {
384            self.pos += 1;
385        }
386    }
387
388    fn skip_block_comment(&mut self) {
389        // We've already consumed '/*', now find '*/'
390        while self.pos + 1 < self.bytes.len() {
391            if self.bytes[self.pos] == b'*' && self.bytes[self.pos + 1] == b'/' {
392                self.pos += 2;
393                return;
394            }
395            self.pos += 1;
396        }
397        // Malformed - skip to end
398        self.pos = self.bytes.len();
399    }
400
401    /// Handle a single-quoted string: emit '?'
402    fn handle_single_quote(&mut self) {
403        let str_start = self.pos;
404        if let Some(end) = find_quoted_string_end(self.bytes, self.pos) {
405            self.pos = end;
406        } else {
407            // Unterminated string: consume to end of input, emit ?
408            self.pos = self.bytes.len();
409        }
410        if self.pending_json_path || self.is_normalize_only() {
411            self.pending_json_path = false;
412            // Keep the string as-is (don't quantize)
413            let raw = &self.s[str_start..self.pos].to_string();
414            if !self.maybe_consume_alias_next() {
415                self.emit(raw);
416            }
417            return;
418        }
419        if !self.maybe_consume_alias_next() {
420            self.emit_placeholder();
421        }
422    }
423
424    /// Called when we're about to emit a real token after 'AS'.
425    /// If we're in alias-stripping mode, truncate result to before 'AS' and return true (skip
426    /// token).
427    fn maybe_consume_alias_next(&mut self) -> bool {
428        if let Some(before_len) = self.before_as_len.take() {
429            // Truncate result to remove the ' AS' we emitted
430            self.result.truncate(before_len);
431            return true; // caller should skip emitting the token
432        }
433        false
434    }
435
436    /// Emit an identifier token (handles NULL/bool/AS).
437    fn emit_identifier(&mut self, ident: &str) {
438        let lower = ident.to_ascii_lowercase();
439
440        // If we're in pending_savepoint state, the next token becomes ?
441        if self.pending_savepoint {
442            self.pending_savepoint = false;
443            if self.maybe_consume_alias_next() {
444                return;
445            }
446            self.emit_placeholder();
447            return;
448        }
449
450        // Handle NULL
451        if !self.config.keep_null && !self.is_normalize_only() && lower == "null" {
452            if self.maybe_consume_alias_next() {
453                return;
454            }
455            self.emit_placeholder();
456            return;
457        }
458
459        // Handle boolean literals
460        if !self.config.keep_boolean
461            && !self.is_normalize_only()
462            && (lower == "true" || lower == "false")
463        {
464            if self.maybe_consume_alias_next() {
465                return;
466            }
467            self.emit_placeholder();
468            return;
469        }
470
471        // Handle AS keyword for alias stripping
472        // Alias stripping applies in legacy mode and normalize modes, but NOT in obfuscate_only
473        // (go-sqllexer obfuscator does not strip aliases)
474        if !self.config.keep_sql_alias
475            && !self.is_normalize_only()
476            && !self.is_obfuscate_only()
477            && lower == "as"
478        {
479            // Don't consume alias here - emit AS but remember where to truncate
480            // Trim trailing space from result if any
481            if self.last_char() == Some(b' ') {
482                self.result.pop();
483            }
484            let before_len = self.result.len();
485            self.space();
486            self.result.push_str(ident);
487            self.before_as_len = Some(before_len);
488            // Go's groupingFilter resets when it sees AS (non-groupable, non-paren, non-comma).
489            // Reset last_was_placeholder so the comma after `alias` is not stripped.
490            self.last_was_placeholder = false;
491            return;
492        }
493
494        // SQL control-flow keywords should NOT be consumed as aliases
495        // (e.g., `CREATE PROCEDURE TestProc AS BEGIN ...` — BEGIN is not an alias)
496        // The `AS` is already in self.result; just clear before_as_len to keep it.
497        // Go's discardFilter discards the token after AS unconditionally (except '[' which triggers
498        // MSSQL bracketed identifier mode). However, SQL block-start keywords like BEGIN should not
499        // be consumed as aliases (e.g., CREATE PROCEDURE ... AS BEGIN).
500        // We keep the exclusion list minimal: only true SQL block-starters that cannot be aliases.
501        if self.before_as_len.is_some()
502            && matches!(
503                lower.as_str(),
504                "begin"
505                    | "end"
506                    | "select"
507                    | "insert"
508                    | "update"
509                    | "delete"
510                    | "from"
511                    | "where"
512                    | "join"
513                    | "on"
514                    | "set"
515                    | "values"
516                    | "into"
517                    | "group"
518                    | "order"
519                    | "having"
520                    | "union"
521                    | "intersect"
522                    | "except"
523                    | "limit"
524                    | "offset"
525                    | "with"
526                    | "create"
527                    | "drop"
528                    | "alter"
529                    | "truncate"
530            )
531        {
532            self.before_as_len = None;
533        }
534
535        if self.maybe_consume_alias_next() {
536            return;
537        }
538
539        // After emitting SAVEPOINT, the next identifier/literal becomes ?
540        if lower == "savepoint" {
541            self.pending_savepoint = true;
542        }
543
544        let out = if self.config.replace_digits {
545            apply_replace_digits(ident)
546        } else {
547            ident.to_string()
548        };
549        self.emit(&out);
550    }
551
552    /// After emitting a backtick/double-quote identifier, check if followed by '.' and another
553    /// quoted ident.
554    fn handle_dot_after_quoted_ident(&mut self) {
555        if !self.at_end() && self.bytes[self.pos] == b'.' {
556            let next = self.bytes.get(self.pos + 1).copied();
557            // In obfuscate_only mode, preserve input spacing (no spaces around dots)
558            if self.is_obfuscate_only() {
559                self.result.push('.');
560                self.pos += 1;
561                return;
562            }
563            match next {
564                Some(b'`' | b'"' | b'[') => {
565                    self.result.push_str(" . ");
566                    self.pos += 1; // skip '.'
567                }
568                Some(b'*') => {
569                    self.result.push_str(".*");
570                    self.pos += 2;
571                }
572                Some(c) if is_ident_start(c) => {
573                    self.result.push_str(" . ");
574                    self.pos += 1; // skip '.'
575                }
576                _ => {
577                    self.result.push('.');
578                    self.pos += 1;
579                }
580            }
581        }
582    }
583
584    /// After emitting a bracket identifier, check if followed by '.' and another bracket ident.
585    fn handle_dot_after_bracket_ident(&mut self) {
586        if !self.at_end() && self.bytes[self.pos] == b'.' {
587            let next = self.bytes.get(self.pos + 1).copied();
588            if next == Some(b'[') {
589                self.result.push_str(" . ");
590            } else {
591                self.result.push('.');
592            }
593            self.pos += 1;
594        }
595    }
596
597    /// Consume and emit the rest of a numeric literal starting at current pos.
598    fn consume_number(&mut self) {
599        self.consume_number_inner(false);
600    }
601
602    fn consume_number_inner(&mut self, seen_dot: bool) {
603        // Consume digits, '.', 'e'/'E', optional sign after 'e', suffix letters.
604        // `seen_dot`: true when caller already consumed the leading '.', so don't allow another.
605        // This mirrors Go's scanNumber(seenDecimalPoint) which goes straight to `exponent`
606        // without looping back to `fraction`, leaving a second '.' as a separate token.
607        let mut saw_dot = seen_dot;
608        let mut saw_exp = false;
609        while !self.at_end() {
610            let b = self.bytes[self.pos];
611            match b {
612                b'0'..=b'9' => {
613                    self.pos += 1;
614                }
615                b'.' if !saw_dot => {
616                    saw_dot = true;
617                    self.pos += 1;
618                }
619                b'e' | b'E' if !saw_exp => {
620                    saw_exp = true;
621                    self.pos += 1;
622                    // optional sign
623                    if !self.at_end() && matches!(self.bytes[self.pos], b'+' | b'-') {
624                        self.pos += 1;
625                    }
626                }
627                // Note: letter suffixes like 'f'/'d'/'l' are NOT consumed here.
628                // Go's old SQL tokenizer does not treat them as numeric suffixes,
629                // so "0D" parses as number "0" + identifier "D".
630                _ => break,
631            }
632        }
633    }
634
635    #[allow(clippy::too_many_lines, reason = "FIXME: split this function")]
636    fn process(&mut self) {
637        while !self.at_end() {
638            let b = self.bytes[self.pos];
639
640            match b {
641                // Whitespace: normalize to single space
642                b if is_whitespace(b) => {
643                    self.pos += 1;
644                    self.skip_whitespace();
645                    // Don't push space if we're in alias-stripping mode (waiting for next token)
646                    if self.before_as_len.is_none() {
647                        self.space();
648                    }
649                }
650
651                // Line comment: -- ... (also // like Go's old tokenizer)
652                b'-' if self.peek(1) == Some(b'-') => {
653                    self.pos += 2;
654                    self.skip_line_comment();
655                }
656                b'/' if self.peek(1) == Some(b'/') => {
657                    self.pos += 2;
658                    self.skip_line_comment();
659                }
660
661                // MySQL-style comment: # ...
662                // In Go's old tokenizer, # is ALWAYS a comment unless DBMS is SQL Server.
663                b'#' => {
664                    let next = self.peek(1);
665                    let is_sqlserver = matches!(self.dbms, DbmsKind::Mssql);
666                    match next {
667                        Some(b)
668                            if is_sqlserver
669                                && (b.is_ascii_alphanumeric() || b == b'_' || b == b'#') =>
670                        {
671                            // SQL Server temp table identifier like #temp or ##global
672                            let start = self.pos;
673                            while !self.at_end() && is_ident_char(self.bytes[self.pos]) {
674                                self.pos += 1;
675                            }
676                            let ident = &self.s[start..self.pos];
677                            if self.maybe_consume_alias_next() {
678                                continue;
679                            }
680                            let out = if self.config.replace_digits {
681                                apply_replace_digits(ident)
682                            } else {
683                                ident.to_string()
684                            };
685                            self.emit(&out);
686                        }
687                        // PostgreSQL JSON operators: #>, #>>, #-
688                        Some(b'>') if matches!(self.dbms, DbmsKind::Postgresql) => {
689                            if self.maybe_consume_alias_next() {
690                                continue;
691                            }
692                            if self.peek(2) == Some(b'>') {
693                                self.emit("#>>");
694                                self.pos += 3;
695                            } else {
696                                self.emit("#>");
697                                self.pos += 2;
698                            }
699                            self.space();
700                        }
701                        Some(b'-') if matches!(self.dbms, DbmsKind::Postgresql) => {
702                            if self.maybe_consume_alias_next() {
703                                continue;
704                            }
705                            self.emit("#-");
706                            self.pos += 2;
707                            self.space();
708                        }
709                        _ => {
710                            // MySQL-style comment: skip to end of line
711                            self.pos += 1;
712                            self.skip_line_comment();
713                        }
714                    }
715                }
716
717                // Block comment: /* ... */
718                b'/' if self.peek(1) == Some(b'*') => {
719                    self.pos += 2;
720                    self.skip_block_comment();
721                }
722
723                // Semicolon
724                b';' => {
725                    self.pos += 1;
726                    // In old tokenizer mode (obfuscation_mode=""), Go ALWAYS strips semicolons.
727                    // Go's discardFilter marks ';' as filterable-groupable, so the next '?'
728                    // gets grouped/dropped. Set last_was_placeholder to replicate this.
729                    if self.is_obfuscate_only()
730                        || (!self.is_unspecified_obfuscate_mode()
731                            && self.config.keep_trailing_semicolon)
732                    {
733                        if self.maybe_consume_alias_next() {
734                            continue;
735                        }
736                        self.result.push(';');
737                    } else if self.is_unspecified_obfuscate_mode() {
738                        // Mark as "filterable groupable" so next ? is grouped (Go behavior)
739                        self.last_was_placeholder = true;
740                    }
741                }
742
743                // Opening paren
744                b'(' => {
745                    if self.before_as_len.is_some() && !self.is_unspecified_obfuscate_mode() {
746                        // In obfuscate_and_normalize mode: keep AS before ( (CTE body)
747                        self.before_as_len = None;
748                    } else if self.before_as_len.is_some() {
749                        // Legacy mode: Go discards the token immediately after AS, including '('.
750                        // Strip AS from result and skip '(' (matching Go's discardFilter behavior).
751                        self.maybe_consume_alias_next();
752                        self.pos += 1;
753                        self.skip_whitespace();
754                        continue; // skip emitting '('
755                    }
756                    self.pending_savepoint = false;
757                    self.space();
758                    self.result.push('(');
759                    self.pos += 1;
760                    // In old-tokenizer mode (obfuscation_mode=""), Go always adds a space after '('
761                    // regardless of remove_space_between_parentheses. Only suppress in new modes.
762                    let add_space = if self.is_unspecified_obfuscate_mode() {
763                        !self.is_obfuscate_only()
764                    } else {
765                        !self.config.remove_space_between_parentheses && !self.is_obfuscate_only()
766                    };
767                    if add_space {
768                        self.skip_whitespace();
769                        self.result.push(' ');
770                    }
771                }
772
773                // Closing paren
774                b')' => {
775                    if self.maybe_consume_alias_next() {
776                        continue;
777                    }
778                    // In old-tokenizer mode, Go always adds spaces before ')'
779                    let add_close_space = if self.is_unspecified_obfuscate_mode() {
780                        !self.is_obfuscate_only()
781                    } else {
782                        !self.config.remove_space_between_parentheses && !self.is_obfuscate_only()
783                    };
784                    if add_close_space {
785                        // Add space before ) if needed (not after '(' or already spaced)
786                        if !matches!(self.last_char(), Some(b'(' | b' ') | None) {
787                            self.result.push(' ');
788                        }
789                    }
790                    self.result.push(')');
791                    self.pos += 1;
792                    // NOTE: do NOT clear last_was_placeholder here.
793                    // Go's groupingFilter doesn't reset on ')' either, which means
794                    // a comma after `? )` (CTE body ending with a placeholder) is stripped.
795                }
796
797                // Comma
798                b',' => {
799                    if self.maybe_consume_alias_next() {
800                        continue;
801                    }
802                    self.pos += 1;
803                    // Go behavior: commas that follow a standalone placeholder ? are stripped
804                    // Go's groupingFilter comma stripping: when groupFilter > 0 and token == ',',
805                    // discard it. This only applies in legacy mode (obfuscation_mode == "").
806                    // go-sqllexer modes (obfuscate_only, etc.) do NOT strip commas.
807                    if self.last_was_placeholder && self.is_unspecified_obfuscate_mode() {
808                        // Remove any trailing space too
809                        while self.last_char() == Some(b' ') {
810                            self.result.pop();
811                        }
812                        continue;
813                    }
814                    // Remove any trailing space before comma (input may have `token ,`)
815                    if self.last_char() == Some(b' ') {
816                        self.result.pop();
817                    }
818                    self.result.push(',');
819                    // Space after comma handled by next token's space() call
820                }
821
822                // Single-quoted string
823                b'\'' => {
824                    self.handle_single_quote();
825                }
826
827                // Backtick identifier (MySQL-style, handle doubled backtick escaping)
828                b'`' => {
829                    self.pos += 1;
830                    let mut ident_buf = String::new();
831                    loop {
832                        if self.at_end() {
833                            break;
834                        }
835                        if self.bytes[self.pos] == b'`' {
836                            if self.bytes.get(self.pos + 1) == Some(&b'`') {
837                                // Escaped backtick
838                                ident_buf.push('`');
839                                self.pos += 2;
840                            } else {
841                                self.pos += 1; // skip closing backtick
842                                break;
843                            }
844                        } else {
845                            let c = self.s[self.pos..].chars().next().unwrap_or(' ');
846                            ident_buf.push(c);
847                            self.pos += c.len_utf8();
848                        }
849                    }
850                    // Empty/whitespace-only backtick identifiers must keep their delimiters
851                    // to avoid producing invalid SQL (matches Go's scanString behavior).
852                    let out = if ident_buf.chars().all(char::is_whitespace) {
853                        format!("`{ident_buf}`")
854                    } else if self.config.replace_digits {
855                        apply_replace_digits(&ident_buf)
856                    } else {
857                        ident_buf.clone()
858                    };
859                    if self.maybe_consume_alias_next() {
860                        // The alias token is consumed
861                    } else {
862                        self.emit(&out);
863                        self.handle_dot_after_quoted_ident();
864                    }
865                }
866
867                // Double-quoted identifier
868                b'"' => {
869                    self.pos += 1;
870                    // Scan double-quoted identifier, decoding "" escape sequences to single "
871                    let mut ident_buf = String::new();
872                    while !self.at_end() {
873                        if self.bytes[self.pos] == b'"' {
874                            if self.bytes.get(self.pos + 1) == Some(&b'"') {
875                                ident_buf.push('"'); // "" → one "
876                                self.pos += 2;
877                            } else {
878                                break;
879                            }
880                        } else {
881                            let ch = self.s[self.pos..].chars().next().unwrap_or('\0');
882                            ident_buf.push(ch);
883                            self.pos += ch.len_utf8();
884                        }
885                    }
886                    let ident_owned = ident_buf;
887                    let ident = ident_owned.as_str();
888                    if !self.at_end() {
889                        self.pos += 1; // skip closing quote
890                    }
891                    // If last token was = (assignment/comparison), treat double-quoted string
892                    // as a value (quantize), not as an identifier.
893                    let is_string_value = self.last_was_assign;
894                    // If pending SAVEPOINT or empty/whitespace content, treat as literal → ?
895                    if self.pending_savepoint
896                        || (!ident.is_empty() && ident.chars().all(char::is_whitespace))
897                        || (!self.is_normalize_only() && is_string_value)
898                    {
899                        self.pending_savepoint = false;
900                        if self.maybe_consume_alias_next() {
901                            // consumed
902                        } else {
903                            self.emit_placeholder();
904                            self.handle_dot_after_quoted_ident();
905                        }
906                    } else if (self.config.keep_identifier_quotation
907                        && !self.is_unspecified_obfuscate_mode())
908                        || self.is_obfuscate_only()
909                    {
910                        // Keep original double-quote syntax (go-sqllexer obfuscate_only keeps
911                        // quotes) In old tokenizer mode,
912                        // keep_identifier_quotation is ignored (like Go).
913                        let quoted = format!("\"{ident}\"");
914                        if self.maybe_consume_alias_next() {
915                            // consumed
916                        } else {
917                            self.emit(&quoted);
918                            self.handle_dot_after_quoted_ident();
919                        }
920                    } else {
921                        // For empty identifiers, keep quotes (empty ident without quotes = invalid
922                        // SQL) Go's replaceFilter never applies
923                        // replace_digits to DoubleQuotedString tokens (only
924                        // ID/TableName), so we never digit-replace quoted identifier content.
925                        let out = if ident.is_empty() {
926                            format!("\"{ident}\"")
927                        } else {
928                            ident.to_string()
929                        };
930                        if self.maybe_consume_alias_next() {
931                            // consumed
932                        } else {
933                            self.emit(&out);
934                            self.handle_dot_after_quoted_ident();
935                        }
936                    }
937                }
938
939                // Square bracket identifier [...]
940                b'[' => {
941                    if matches!(self.dbms, DbmsKind::Mssql) {
942                        self.pos += 1;
943                        let id_start = self.pos;
944                        while !self.at_end() && self.bytes[self.pos] != b']' {
945                            self.pos += 1;
946                        }
947                        let ident = &self.s[id_start..self.pos];
948                        if !self.at_end() {
949                            self.pos += 1; // skip ']'
950                        }
951                        if self.maybe_consume_alias_next() {
952                            // consumed
953                        } else {
954                            let out = if self.config.replace_digits {
955                                apply_replace_digits(ident)
956                            } else {
957                                ident.to_string()
958                            };
959                            self.emit(&out);
960                            self.handle_dot_after_bracket_ident();
961                        }
962                    } else {
963                        // Non-mssql: emit [ as operator, let content be tokenized normally
964                        // But if in alias mode, consume the whole [...] block as the alias
965                        if self.before_as_len.is_some() {
966                            self.pos += 1; // skip '['
967                            while !self.at_end() && self.bytes[self.pos] != b']' {
968                                self.pos += 1;
969                            }
970                            if !self.at_end() {
971                                self.pos += 1; // skip ']'
972                            }
973                            self.maybe_consume_alias_next();
974                        } else {
975                            self.space();
976                            self.result.push('[');
977                            self.pos += 1;
978                            self.skip_whitespace();
979                            self.space();
980                        }
981                    }
982                }
983
984                b']' => {
985                    if self.maybe_consume_alias_next() {
986                        continue;
987                    }
988                    if !matches!(self.last_char(), Some(b'[' | b' ') | None) {
989                        self.space();
990                    }
991                    self.result.push(']');
992                    self.pos += 1;
993                    // If followed by '.', emit ' . ' for chained bracket access
994                    if !self.at_end() && self.bytes[self.pos] == b'.' {
995                        self.result.push_str(" . ");
996                        self.pos += 1; // skip '.'
997                    }
998                }
999
1000                // Dollar sign: positional param, dollar-quoted string, or identifier
1001                b'$' => {
1002                    let next = self.peek(1);
1003                    match next {
1004                        // Positional param: $1, $2, $?, $09
1005                        Some(b) if b.is_ascii_digit() || b == b'?' => {
1006                            let token_start = self.pos;
1007                            self.pos += 1; // skip '$'
1008                                           // Go's scanPreparedStatement calls scanNumber which only scans
1009                                           // decimal digits (not all alphanumeric). Letters like 'C' in "$2C"
1010                                           // are NOT part of the positional param.
1011                            while !self.at_end()
1012                                && (self.bytes[self.pos].is_ascii_digit()
1013                                    || self.bytes[self.pos] == b'?')
1014                            {
1015                                self.pos += 1;
1016                            }
1017                            // Go's scanNumber follows the fraction path: a trailing '.' (and any
1018                            // following digits) is consumed as part of the number, e.g. "$0." → "?"
1019                            if !self.at_end() && self.bytes[self.pos] == b'.' {
1020                                self.pos += 1; // consume '.'
1021                                while !self.at_end() && self.bytes[self.pos].is_ascii_digit() {
1022                                    self.pos += 1;
1023                                }
1024                            }
1025                            if self.maybe_consume_alias_next() {
1026                                continue;
1027                            }
1028                            // In old-tokenizer mode (obfuscation_mode=Unspecified), Go always
1029                            // replaces positional parameters regardless
1030                            // of keep_positional_parameter.
1031                            // Only respect keep_positional_parameter in new lexer modes.
1032                            let keep = (self.config.keep_positional_parameter
1033                                && !self.is_unspecified_obfuscate_mode())
1034                                || self.is_normalize_only()
1035                                || self.is_obfuscate_only();
1036                            if keep {
1037                                self.emit(&self.s[token_start..self.pos]);
1038                            } else {
1039                                self.emit_placeholder();
1040                            }
1041                        }
1042                        // Dollar-quoted string: $tag$...$tag$ or $$...$$
1043                        _ if next == Some(b'$')
1044                            || next.is_some_and(|c| c.is_ascii_alphabetic() || c == b'_') =>
1045                        {
1046                            let start = self.pos;
1047                            if let Some((inner_start, inner_end, outer_end)) =
1048                                find_dollar_quote_end(self.bytes, start)
1049                            {
1050                                if self.maybe_consume_alias_next() {
1051                                    self.pos = outer_end;
1052                                    continue;
1053                                }
1054                                if self.is_normalize_only() {
1055                                    // In normalize mode: process inner content with same config
1056                                    let tag_str = &self.s[start..inner_start];
1057                                    let inner = &self.s[inner_start..inner_end];
1058                                    let close_tag = &self.s[inner_end..outer_end];
1059                                    let normalized_inner =
1060                                        obfuscate_sql(inner, self.config, self.dbms);
1061                                    self.space();
1062                                    self.result.push_str(tag_str);
1063                                    self.result.push_str(&normalized_inner);
1064                                    self.result.push_str(close_tag);
1065                                } else if self.config.dollar_quoted_func {
1066                                    // Obfuscate the content inside dollar quotes
1067                                    let tag_str = &self.s[start..inner_start];
1068                                    let inner = &self.s[inner_start..inner_end];
1069                                    let close_tag = &self.s[inner_end..outer_end];
1070                                    let obfuscated_inner =
1071                                        obfuscate_sql(inner, self.config, self.dbms);
1072                                    // If inner collapses to just '?' (trivial content), emit ?
1073                                    // directly
1074                                    if obfuscated_inner.trim() == "?" {
1075                                        self.emit_placeholder();
1076                                    } else {
1077                                        self.space();
1078                                        self.result.push_str(tag_str);
1079                                        self.result.push_str(&obfuscated_inner);
1080                                        self.result.push_str(close_tag);
1081                                    }
1082                                } else {
1083                                    // Replace whole thing with ?
1084                                    self.emit_placeholder();
1085                                }
1086                                self.pos = outer_end;
1087                            } else {
1088                                // Not a valid dollar quote, check if it's an identifier starting
1089                                // with $
1090                                self.pos += 1; // skip '$'
1091                                let id_start_pos = self.pos;
1092                                while !self.at_end()
1093                                    && (is_ident_char(self.bytes[self.pos])
1094                                        || self.bytes[self.pos] == b'$')
1095                                {
1096                                    self.pos += 1;
1097                                }
1098                                let ident = &self.s[id_start_pos - 1..self.pos]; // include '$'
1099                                if self.maybe_consume_alias_next() {
1100                                    continue;
1101                                }
1102                                self.emit(ident);
1103                            }
1104                            let _ = b; // suppress unused warning
1105                        }
1106                        _ => {
1107                            // $identifier like $action - keep as-is
1108                            let start = self.pos;
1109                            self.pos += 1; // skip '$'
1110                            while !self.at_end()
1111                                && (is_ident_char(self.bytes[self.pos])
1112                                    || self.bytes[self.pos] == b'$')
1113                            {
1114                                self.pos += 1;
1115                            }
1116                            let token = &self.s[start..self.pos];
1117                            if self.maybe_consume_alias_next() {
1118                                continue;
1119                            }
1120                            self.emit(token);
1121                        }
1122                    }
1123                }
1124
1125                // Hex literal: 0x...
1126                b'0' if matches!(self.peek(1), Some(b'x' | b'X')) => {
1127                    self.pos += 2; // skip '0x'
1128                    while !self.at_end() && self.bytes[self.pos].is_ascii_hexdigit() {
1129                        self.pos += 1;
1130                    }
1131                    if self.maybe_consume_alias_next() {
1132                        continue;
1133                    }
1134                    self.emit_placeholder();
1135                }
1136
1137                // Hex literal: X'...' or x'...'
1138                b'X' | b'x' if self.peek(1) == Some(b'\'') => {
1139                    self.pos += 1; // skip 'X'/'x'
1140                    if let Some(end) = find_quoted_string_end(self.bytes, self.pos) {
1141                        self.pos = end;
1142                    } else {
1143                        self.pos += 1;
1144                    }
1145                    if self.maybe_consume_alias_next() {
1146                        continue;
1147                    }
1148                    self.emit_placeholder();
1149                }
1150
1151                // % bind param: %s, %d, %b, %i, %(name)s
1152                b'%' => {
1153                    let next = self.peek(1);
1154                    match next {
1155                        Some(b)
1156                            if b.is_ascii_alphabetic() || b == b'_' || b == b'@' || b == b'#' =>
1157                        {
1158                            // Any ASCII letter/underscore/@/# after % is a format parameter
1159                            // (Go's scanFormatParameter handles all isLetter chars)
1160                            self.pos += 2;
1161                            if self.maybe_consume_alias_next() {
1162                                continue;
1163                            }
1164                            self.emit_placeholder();
1165                        }
1166                        Some(b'(') => {
1167                            self.pos += 2; // skip '%('
1168                            while !self.at_end() && self.bytes[self.pos] != b')' {
1169                                self.pos += 1;
1170                            }
1171                            if !self.at_end() {
1172                                self.pos += 1;
1173                            } // skip ')'
1174                              // Skip the format character
1175                            if !self.at_end() && self.bytes[self.pos].is_ascii_alphabetic() {
1176                                self.pos += 1;
1177                            }
1178                            if self.maybe_consume_alias_next() {
1179                                continue;
1180                            }
1181                            self.emit_placeholder();
1182                        }
1183                        Some(b) if b > 127 => {
1184                            // Non-ASCII byte: check if it starts a Unicode letter.
1185                            // Go's old SQL tokenizer treats %<letter> as a format parameter
1186                            // (Variable token) which gets obfuscated to '?'.
1187                            let next_char = self.s[self.pos + 1..].chars().next();
1188                            if let Some(nc) = next_char.filter(|c| c.is_alphabetic() || *c == '_') {
1189                                let skip = 1 + nc.len_utf8();
1190                                self.pos += skip;
1191                                if self.maybe_consume_alias_next() {
1192                                    continue;
1193                                }
1194                                self.emit_placeholder();
1195                            } else {
1196                                if self.maybe_consume_alias_next() {
1197                                    continue;
1198                                }
1199                                self.space();
1200                                self.result.push('%');
1201                                self.pos += 1;
1202                                self.space();
1203                            }
1204                        }
1205                        _ => {
1206                            // Just a % sign - emit as operator
1207                            if self.maybe_consume_alias_next() {
1208                                continue;
1209                            }
1210                            self.space();
1211                            self.result.push('%');
1212                            self.pos += 1;
1213                            self.space();
1214                        }
1215                    }
1216                }
1217
1218                // Number starting with '.'
1219                // Only treat .digit as a float literal if NOT preceded by an identifier char.
1220                // When preceded by an identifier, '.2' is a qualifier (Go's scanIdentifier includes
1221                // '.')
1222                b'.' if self.peek(1).is_some_and(|b| b.is_ascii_digit())
1223                    && !self
1224                        .last_char()
1225                        .is_some_and(|b| is_ident_char(b) || b == b'"' || b == b'`') =>
1226                {
1227                    let num_start = self.pos;
1228                    self.pos += 1; // skip '.'
1229                                   // Go's scanNumber(seenDecimalPoint=true) doesn't loop back to fraction,
1230                                   // so a second '.' is NOT consumed (e.g. ".0.x" → number ".0" + dot + "x")
1231                    self.consume_number_inner(true);
1232                    if self.maybe_consume_alias_next() {
1233                        continue;
1234                    }
1235                    if self.is_normalize_only() {
1236                        let raw = self.s[num_start..self.pos].to_string();
1237                        self.emit(&raw);
1238                    } else {
1239                        self.emit_placeholder();
1240                    }
1241                }
1242
1243                // Plain dot (qualifier separator)
1244                // When preceded by an identifier and followed by digits, Go's scanIdentifier
1245                // includes '.' in the identifier (see ContainsRune(".*$",...)).
1246                // We need to handle ident.digit as a single identifier-like token.
1247                b'.' => {
1248                    let after_dot_is_digit = self.peek(1).is_some_and(|b| b.is_ascii_digit());
1249                    let preceded_by_ident = self
1250                        .last_char()
1251                        .is_some_and(|b| is_ident_char(b) || b == b'"' || b == b'`');
1252                    if after_dot_is_digit && preceded_by_ident {
1253                        // handled below
1254                    } else if !after_dot_is_digit && !preceded_by_ident {
1255                        // Standalone dot not after identifier: needs space (Go adds space before
1256                        // every token)
1257                        if self.maybe_consume_alias_next() {
1258                            continue;
1259                        }
1260                        self.space();
1261                        self.result.push('.');
1262                        self.pos += 1;
1263                        continue;
1264                    }
1265                    if after_dot_is_digit && preceded_by_ident {
1266                        // Scan the rest of the identifier (including .digit parts) as one unit
1267                        let start = self.pos; // start at '.'
1268                        self.pos += 1; // skip '.'
1269                        while !self.at_end()
1270                            && (is_ident_char(self.bytes[self.pos]) || self.bytes[self.pos] == b'.')
1271                        {
1272                            if self.bytes[self.pos] == b'.' {
1273                                if self
1274                                    .peek(1)
1275                                    .is_some_and(|b| b.is_ascii_digit() || is_ident_char(b))
1276                                {
1277                                    self.pos += 1;
1278                                } else {
1279                                    break;
1280                                }
1281                            } else {
1282                                self.pos += 1;
1283                            }
1284                        }
1285                        let suffix = &self.s[start..self.pos];
1286                        // Apply replace_digits if configured, otherwise emit as-is
1287                        let out = if self.config.replace_digits {
1288                            apply_replace_digits(suffix)
1289                        } else {
1290                            suffix.to_string()
1291                        };
1292                        // Append directly to result (already have the identifier prefix)
1293                        self.result.push_str(&out);
1294                    } else {
1295                        self.result.push('.');
1296                        self.pos += 1;
1297                    }
1298                }
1299
1300                // Numeric literal
1301                b'0'..=b'9' => {
1302                    let num_start = self.pos;
1303                    self.consume_number();
1304                    if self.maybe_consume_alias_next() {
1305                        continue;
1306                    }
1307                    if self.pending_json_path || self.is_normalize_only() {
1308                        self.pending_json_path = false;
1309                        let raw = self.s[num_start..self.pos].to_string();
1310                        self.emit(&raw);
1311                    } else {
1312                        self.emit_placeholder();
1313                    }
1314                }
1315
1316                // Curly braces { ... }
1317                b'{' => {
1318                    if self.maybe_consume_alias_next() {
1319                        continue;
1320                    }
1321                    // Peek at content after { and optional whitespace
1322                    let mut peek_pos = self.pos + 1;
1323                    while peek_pos < self.bytes.len() && is_whitespace(self.bytes[peek_pos]) {
1324                        peek_pos += 1;
1325                    }
1326                    // ODBC stored proc: {call ...} — keep outer braces, tokenize content normally
1327                    let is_call = peek_pos + 4 <= self.bytes.len()
1328                        && self.bytes[peek_pos..peek_pos + 4].eq_ignore_ascii_case(b"call")
1329                        && (peek_pos + 4 >= self.bytes.len()
1330                            || !self.bytes[peek_pos + 4].is_ascii_alphanumeric());
1331                    if is_call {
1332                        self.space();
1333                        self.result.push('{');
1334                        self.pos += 1;
1335                        self.skip_whitespace();
1336                        self.result.push(' ');
1337                    } else {
1338                        // Cassandra maps, {fn ...}, etc. → scan to matching } and emit ?
1339                        let mut depth = 1usize;
1340                        self.pos += 1; // skip '{'
1341                        while !self.at_end() && depth > 0 {
1342                            match self.bytes[self.pos] {
1343                                b'{' => {
1344                                    depth += 1;
1345                                    self.pos += 1;
1346                                }
1347                                b'}' => {
1348                                    depth -= 1;
1349                                    self.pos += 1;
1350                                }
1351                                b'\'' => {
1352                                    if let Some(end) = find_quoted_string_end(self.bytes, self.pos)
1353                                    {
1354                                        self.pos = end;
1355                                    } else {
1356                                        self.pos += 1;
1357                                    }
1358                                }
1359                                _ => {
1360                                    self.pos += 1;
1361                                }
1362                            }
1363                        }
1364                        self.emit_placeholder();
1365                    }
1366                }
1367                b'}' => {
1368                    if self.maybe_consume_alias_next() {
1369                        continue;
1370                    }
1371                    // Unmatched closing brace — emit as operator
1372                    self.space();
1373                    self.result.push('}');
1374                    self.pos += 1;
1375                }
1376
1377                // @ - named params (@name, @1, @@var) - keep as-is
1378                b'@' => {
1379                    if self.peek(1) == Some(b'@') {
1380                        // @@global_var
1381                        let start = self.pos;
1382                        self.pos += 2; // skip '@@'
1383                        while !self.at_end() && is_ident_char(self.bytes[self.pos]) {
1384                            self.pos += 1;
1385                        }
1386                        let token = &self.s[start..self.pos];
1387                        if self.maybe_consume_alias_next() {
1388                            continue;
1389                        }
1390                        self.emit(token);
1391                    } else if self.peek(1).is_some_and(|b| {
1392                        b.is_ascii_alphanumeric()
1393                            || b == b'_'
1394                            || b == b'#'
1395                            || b == b'$'
1396                            || b == b'*'
1397                    }) {
1398                        // @name, @1, @#name, @$name, @*name — all valid Go ident chars (ASCII)
1399                        let start = self.pos;
1400                        self.pos += 1; // skip '@'
1401                        while !self.at_end() && is_ident_char(self.bytes[self.pos]) {
1402                            self.pos += 1;
1403                        }
1404                        let token = self.s[start..self.pos].to_string();
1405                        if self.maybe_consume_alias_next() {
1406                            continue;
1407                        }
1408                        // Go's old tokenizer keeps @ prefix and only applies replace_digits.
1409                        // In new modes (obfuscation_mode!=""), respect replace_bind_parameter.
1410                        if self.config.replace_digits && token.chars().any(|c| c.is_ascii_digit()) {
1411                            let replaced = apply_replace_digits(&token);
1412                            self.emit(&replaced);
1413                        } else if self.config.replace_bind_parameter
1414                            && !self.is_unspecified_obfuscate_mode()
1415                        {
1416                            self.emit_placeholder();
1417                        } else {
1418                            self.emit(&token);
1419                        }
1420                    } else if self.peek(1).is_some_and(|b| b > 127) {
1421                        // @unicodeLetter — Go's isAlphaNumeric includes Unicode letters
1422                        let next_char = self.s[self.pos + 1..].chars().next();
1423                        if next_char.is_some_and(|c| c.is_alphabetic() || c == '_') {
1424                            let start = self.pos;
1425                            self.pos += 1; // skip '@'
1426                            while !self.at_end() {
1427                                if self.bytes[self.pos] == b'#' || self.bytes[self.pos] == b'@' {
1428                                    self.pos += 1;
1429                                    continue;
1430                                }
1431                                let rest = &self.s[self.pos..];
1432                                match rest.chars().next() {
1433                                    Some(c) if c.is_alphanumeric() || c == '_' => {
1434                                        self.pos += c.len_utf8();
1435                                    }
1436                                    _ => break,
1437                                }
1438                            }
1439                            let token = self.s[start..self.pos].to_string();
1440                            if self.maybe_consume_alias_next() {
1441                                continue;
1442                            }
1443                            if self.config.replace_digits {
1444                                self.emit(&apply_replace_digits(&token));
1445                            } else {
1446                                self.emit(&token);
1447                            }
1448                        } else {
1449                            if self.maybe_consume_alias_next() {
1450                                continue;
1451                            }
1452                            self.space();
1453                            self.result.push('@');
1454                            self.pos += 1;
1455                            self.last_was_placeholder = false;
1456                            self.result.push(' ');
1457                        }
1458                    } else if self.peek(1) == Some(b'>') {
1459                        // @> operator
1460                        if self.maybe_consume_alias_next() {
1461                            continue;
1462                        }
1463                        self.emit("@>");
1464                        self.pos += 2;
1465                        self.result.push(' ');
1466                    } else {
1467                        // @ as standalone operator
1468                        if self.maybe_consume_alias_next() {
1469                            continue;
1470                        }
1471                        self.space();
1472                        self.result.push('@');
1473                        self.pos += 1;
1474                        self.last_was_placeholder = false;
1475                        self.result.push(' ');
1476                    }
1477                }
1478
1479                // Colon: ::, :=, :name, or standalone
1480                b':' => {
1481                    match self.peek(1) {
1482                        Some(b':') => {
1483                            // :: PostgreSQL cast
1484                            if self.maybe_consume_alias_next() {
1485                                continue;
1486                            }
1487                            self.space();
1488                            self.result.push_str("::");
1489                            self.pos += 2;
1490                            self.last_was_placeholder = false;
1491                            self.result.push(' ');
1492                        }
1493                        Some(b'=') => {
1494                            // := assignment
1495                            if self.maybe_consume_alias_next() {
1496                                continue;
1497                            }
1498                            self.space();
1499                            self.result.push_str(":=");
1500                            self.pos += 2;
1501                            self.last_was_placeholder = false;
1502                            self.result.push(' ');
1503                        }
1504                        Some(b)
1505                            if b.is_ascii_alphanumeric() || b == b'_' || b == b'#' || b == b'@' =>
1506                        {
1507                            // :name bind parameter - keep as-is; '#' is valid in Go identifiers;
1508                            // '@' is isLeadingLetter in Go
1509                            let start = self.pos;
1510                            self.pos += 1; // skip ':'
1511                                           // Go's scanBindVar loops while isLetter || isDigit || ch == '.'
1512                            while !self.at_end()
1513                                && (is_ident_char(self.bytes[self.pos])
1514                                    || self.bytes[self.pos] == b'.')
1515                            {
1516                                self.pos += 1;
1517                            }
1518                            let token = &self.s[start..self.pos];
1519                            if self.maybe_consume_alias_next() {
1520                                continue;
1521                            }
1522                            self.emit(token);
1523                            // If the bind var ends with '.', push a trailing space so that
1524                            // the next token doesn't get its space suppressed by space()'s
1525                            // qualifier-separator logic (which checks last_char == '.').
1526                            if token.ends_with('.') {
1527                                self.result.push(' ');
1528                            }
1529                        }
1530                        Some(b) if b > 127 => {
1531                            // Non-ASCII byte: check if it starts a Unicode letter.
1532                            // Go's normalizer emits ':' and a following IDENT without a space
1533                            // in bind-variable context. Treat ':unicodeword' as a single token.
1534                            let next_char = self.s[self.pos + 1..].chars().next();
1535                            if next_char.is_some_and(|c| c.is_alphabetic() || c == '_') {
1536                                let start = self.pos;
1537                                self.pos += 1; // skip ':'
1538                                while !self.at_end() {
1539                                    // '#' and '@' are valid Go identifier chars (isLetter includes
1540                                    // them). '.' is also valid:
1541                                    // Go's scanBindVar loops while isLetter || isDigit || ch == '.'
1542                                    if self.bytes[self.pos] == b'#'
1543                                        || self.bytes[self.pos] == b'@'
1544                                        || self.bytes[self.pos] == b'.'
1545                                    {
1546                                        self.pos += 1;
1547                                        continue;
1548                                    }
1549                                    let rest = &self.s[self.pos..];
1550                                    match rest.chars().next() {
1551                                        Some(c) if c.is_alphanumeric() || c == '_' => {
1552                                            self.pos += c.len_utf8();
1553                                        }
1554                                        _ => break,
1555                                    }
1556                                }
1557                                let token = &self.s[start..self.pos];
1558                                if self.maybe_consume_alias_next() {
1559                                    continue;
1560                                }
1561                                self.emit(token);
1562                                // If the bind var ends with '.', push a trailing space so that
1563                                // the next token doesn't get its space suppressed by space()'s
1564                                // qualifier-separator logic (which checks last_char == '.').
1565                                if token.ends_with('.') {
1566                                    self.result.push(' ');
1567                                }
1568                            } else {
1569                                if self.maybe_consume_alias_next() {
1570                                    continue;
1571                                }
1572                                self.space();
1573                                self.result.push(':');
1574                                self.pos += 1;
1575                                self.last_was_placeholder = false;
1576                                self.result.push(' ');
1577                            }
1578                        }
1579                        _ => {
1580                            // Standalone : (e.g., autovacuum:)
1581                            if self.maybe_consume_alias_next() {
1582                                continue;
1583                            }
1584                            self.space();
1585                            self.result.push(':');
1586                            self.pos += 1;
1587                            self.last_was_placeholder = false;
1588                            self.result.push(' ');
1589                        }
1590                    }
1591                }
1592
1593                // Minus: --, ->, ->>, or operator, or signed number
1594                b'-' => {
1595                    match self.peek(1) {
1596                        Some(b'-') => {
1597                            // Already handled above, but just in case
1598                            self.pos += 2;
1599                            self.skip_line_comment();
1600                        }
1601                        Some(b'>') if self.peek(2) == Some(b'>') => {
1602                            // ->> operator
1603                            if self.maybe_consume_alias_next() {
1604                                continue;
1605                            }
1606                            self.emit("->>");
1607                            self.pos += 3;
1608                            self.result.push(' ');
1609                            if self.config.keep_json_path {
1610                                self.pending_json_path = true;
1611                            }
1612                        }
1613                        Some(b'>') => {
1614                            // -> operator
1615                            if self.maybe_consume_alias_next() {
1616                                continue;
1617                            }
1618                            self.emit("->");
1619                            self.pos += 2;
1620                            self.result.push(' ');
1621                            if self.config.keep_json_path {
1622                                self.pending_json_path = true;
1623                            }
1624                        }
1625                        Some(b) if b.is_ascii_digit() => {
1626                            // Go's old tokenizer ALWAYS scans -digit as a negative number
1627                            // regardless of preceding context (tkn.lastChar check is only on next
1628                            // char).
1629                            {
1630                                self.pos += 1; // skip '-'
1631                                self.consume_number();
1632                                if self.maybe_consume_alias_next() {
1633                                    continue;
1634                                }
1635                                self.emit_placeholder();
1636                            }
1637                        }
1638                        Some(b'.')
1639                            if self.peek(2).is_some()
1640                                && !self.peek(2).is_some_and(|d| d.is_ascii_digit()) =>
1641                        {
1642                            // '-' followed by '.' followed by a non-digit non-EOF char:
1643                            // Go's tokenizer peeks at '.' then backtracks; because off advances
1644                            // past '.', bytes() captures '-.' as a
1645                            // single token. Emit '-.' together.
1646                            // When '.' is at EOF, Go's advance() doesn't change off (EndChar path),
1647                            // so bytes() only returns '-' — handled by the fallthrough `_` arm.
1648                            //
1649                            // Additionally, Go's advance() for the peek also advances off past the
1650                            // non-digit char ('v' in '-.v5'), making it the first byte of the NEXT
1651                            // token's bytes() output. Replicate that off-leak inline:
1652                            if self.maybe_consume_alias_next() {
1653                                continue;
1654                            }
1655                            self.space();
1656                            self.result.push_str("-.");
1657                            self.pos += 2;
1658                            self.last_was_placeholder = false;
1659                            self.result.push(' ');
1660                            // Handle the off-leak: the char at pos was advanced past by Go's peek.
1661                            // It becomes the first byte of the next token in Go's model.
1662                            if !self.at_end() {
1663                                let c_len =
1664                                    self.s[self.pos..].chars().next().map_or(1, char::len_utf8);
1665                                let after_c = self.pos + c_len;
1666                                if after_c < self.bytes.len()
1667                                    && self.bytes[after_c].is_ascii_digit()
1668                                {
1669                                    // Leaked char + following digits = Number in Go → '?'
1670                                    self.pos = after_c;
1671                                    self.consume_number();
1672                                    self.emit_placeholder();
1673                                } else {
1674                                    // Leaked char becomes the bytes of a '.' token in Go → emit
1675                                    // as-is
1676                                    let n = c_len.min(self.bytes.len() - self.pos);
1677                                    let leaked = self.s[self.pos..self.pos + n].to_owned();
1678                                    self.pos += n;
1679                                    self.result.push_str(&leaked);
1680                                    self.result.push(' ');
1681                                }
1682                            }
1683                        }
1684                        Some(b'.') if self.peek(2).is_some_and(|d| d.is_ascii_digit()) => {
1685                            // -.digit: Go ALWAYS treats this as a signed float number,
1686                            // regardless of the preceding token context.
1687                            self.pos += 2; // skip '-.'
1688                            self.consume_number();
1689                            if self.maybe_consume_alias_next() {
1690                                continue;
1691                            }
1692                            self.emit_placeholder();
1693                        }
1694                        _ => {
1695                            if self.maybe_consume_alias_next() {
1696                                continue;
1697                            }
1698                            self.space();
1699                            self.result.push('-');
1700                            self.pos += 1;
1701                            self.last_was_placeholder = false;
1702                            self.result.push(' ');
1703                        }
1704                    }
1705                }
1706
1707                // Plus: signed number or operator
1708                b'+' => {
1709                    // Go's old SQL tokenizer does NOT consume '+' as part of a signed number.
1710                    // '+' always stays as a separate operator token (unlike '-').
1711                    if self.maybe_consume_alias_next() {
1712                        continue;
1713                    }
1714                    self.space();
1715                    self.result.push('+');
1716                    self.pos += 1;
1717                    self.last_was_placeholder = false;
1718                    self.result.push(' ');
1719                }
1720
1721                // ? - keep as-is (already a placeholder, or JSONB operator)
1722                b'?' => {
1723                    let next = self.peek(1);
1724                    match next {
1725                        Some(b'|') if self.peek(2) != Some(b'|') => {
1726                            // ?| operator (but NOT ?|| which is ? followed by || concatenation)
1727                            if self.maybe_consume_alias_next() {
1728                                continue;
1729                            }
1730                            self.emit("?|");
1731                            self.pos += 2;
1732                            self.result.push(' ');
1733                        }
1734                        Some(b'&') => {
1735                            // ?& operator
1736                            if self.maybe_consume_alias_next() {
1737                                continue;
1738                            }
1739                            self.emit("?&");
1740                            self.pos += 2;
1741                            self.result.push(' ');
1742                        }
1743                        _ => {
1744                            // Raw ? in input:
1745                            // - For postgresql: treat as JSONB operator (not FilteredGroupable).
1746                            //   Don't set last_was_placeholder, so consecutive literals aren't
1747                            //   suppressed.
1748                            // - For other dbms: treat as bind parameter (FilteredGroupable). Use
1749                            //   emit_placeholder so consecutive ?s are suppressed in legacy mode.
1750                            if self.maybe_consume_alias_next() {
1751                                continue;
1752                            }
1753                            if matches!(self.dbms, DbmsKind::Postgresql) {
1754                                self.space();
1755                                self.result.push('?');
1756                                self.last_was_assign = false;
1757                                // last_was_placeholder intentionally NOT set to true for PG JSONB ?
1758                            } else {
1759                                self.emit_placeholder();
1760                            }
1761                            self.pos += 1;
1762                        }
1763                    }
1764                }
1765
1766                // < operator and <@ / <> / <= operators
1767                b'<' => {
1768                    let next = self.peek(1);
1769                    if self.maybe_consume_alias_next() {
1770                        continue;
1771                    }
1772                    match next {
1773                        // <@ containment operator: always PG operator when dbms=postgresql,
1774                        // but when non-PG and followed by identifier, treat as < @ident
1775                        Some(b'@') => {
1776                            let next2_is_ident = self
1777                                .peek(2)
1778                                .is_some_and(|c| c.is_ascii_alphanumeric() || c == b'_');
1779                            if matches!(self.dbms, DbmsKind::Postgresql) || !next2_is_ident {
1780                                self.emit("<@");
1781                                self.pos += 2;
1782                            } else {
1783                                // Non-PG dbms with <@name → emit < then @name handled separately
1784                                self.space();
1785                                self.result.push('<');
1786                                self.pos += 1;
1787                            }
1788                        }
1789                        Some(b'>') => {
1790                            self.emit("<>");
1791                            self.pos += 2;
1792                        }
1793                        Some(b'=') => {
1794                            self.emit("<=");
1795                            self.pos += 2;
1796                        }
1797                        _ => {
1798                            self.space();
1799                            self.result.push('<');
1800                            self.pos += 1;
1801                            self.last_was_placeholder = false;
1802                        }
1803                    }
1804                    self.result.push(' ');
1805                }
1806
1807                // > and >= operators
1808                b'>' => {
1809                    if self.maybe_consume_alias_next() {
1810                        continue;
1811                    }
1812                    if self.peek(1) == Some(b'=') {
1813                        self.emit(">=");
1814                        self.pos += 2;
1815                    } else {
1816                        self.space();
1817                        self.result.push('>');
1818                        self.pos += 1;
1819                        self.last_was_placeholder = false;
1820                    }
1821                    self.result.push(' ');
1822                }
1823
1824                // = operator
1825                b'=' => {
1826                    if self.maybe_consume_alias_next() {
1827                        continue;
1828                    }
1829                    self.space();
1830                    self.result.push('=');
1831                    self.pos += 1;
1832                    self.last_was_placeholder = false;
1833                    self.result.push(' ');
1834                    self.last_was_assign = true;
1835                }
1836
1837                // ! and !=, !~, !~*
1838                b'!' => {
1839                    if self.maybe_consume_alias_next() {
1840                        continue;
1841                    }
1842                    if self.peek(1) == Some(b'=') {
1843                        self.emit("!=");
1844                        self.pos += 2;
1845                    } else if self.peek(1) == Some(b'~') {
1846                        if self.peek(2) == Some(b'*') {
1847                            self.emit("!~*");
1848                            self.pos += 3;
1849                        } else {
1850                            self.emit("!~");
1851                            self.pos += 2;
1852                        }
1853                    } else {
1854                        self.space();
1855                        self.result.push('!');
1856                        self.pos += 1;
1857                        self.last_was_placeholder = false;
1858                    }
1859                    self.result.push(' ');
1860                }
1861
1862                // | and ||
1863                b'|' => {
1864                    if self.maybe_consume_alias_next() {
1865                        continue;
1866                    }
1867                    // Emit single | (Go tokenizes || as two separate | tokens, each with spaces)
1868                    self.space();
1869                    self.result.push('|');
1870                    self.pos += 1;
1871                    self.last_was_placeholder = false;
1872                    self.result.push(' ');
1873                }
1874
1875                // & operator
1876                b'&' => {
1877                    if self.maybe_consume_alias_next() {
1878                        continue;
1879                    }
1880                    self.space();
1881                    self.result.push('&');
1882                    self.pos += 1;
1883                    self.last_was_placeholder = false;
1884                    self.result.push(' ');
1885                }
1886
1887                // ~ ^ operators (and ~* compound)
1888                b'~' => {
1889                    if self.maybe_consume_alias_next() {
1890                        continue;
1891                    }
1892                    if self.peek(1) == Some(b'*') {
1893                        self.emit("~*");
1894                        self.pos += 2;
1895                    } else {
1896                        self.space();
1897                        self.result.push('~');
1898                        self.pos += 1;
1899                        self.last_was_placeholder = false;
1900                    }
1901                    self.result.push(' ');
1902                }
1903                b'^' => {
1904                    if self.maybe_consume_alias_next() {
1905                        continue;
1906                    }
1907                    self.space();
1908                    self.result.push('^');
1909                    self.pos += 1;
1910                    self.last_was_placeholder = false;
1911                    self.result.push(' ');
1912                }
1913
1914                // * operator (or SELECT *)
1915                b'*' => {
1916                    if self.maybe_consume_alias_next() {
1917                        continue;
1918                    }
1919                    self.space();
1920                    self.result.push('*');
1921                    self.pos += 1;
1922                    self.last_was_placeholder = false;
1923                    self.result.push(' ');
1924                }
1925
1926                // / operator (not /*, which is handled above)
1927                b'/' => {
1928                    if self.maybe_consume_alias_next() {
1929                        continue;
1930                    }
1931                    self.space();
1932                    self.result.push('/');
1933                    self.pos += 1;
1934                    self.last_was_placeholder = false;
1935                    self.result.push(' ');
1936                }
1937
1938                // Unicode whitespace (e.g. U+2003 EM SPACE): Go uses unicode.IsSpace
1939                b if b > 127
1940                    && self.s[self.pos..]
1941                        .chars()
1942                        .next()
1943                        .is_some_and(char::is_whitespace) =>
1944                {
1945                    let c = self.s[self.pos..].chars().next().unwrap_or(' ');
1946                    self.pos += c.len_utf8();
1947                    self.skip_whitespace();
1948                    if self.before_as_len.is_none() {
1949                        self.space();
1950                    }
1951                }
1952
1953                // Identifier or keyword
1954                _ if is_ident_start(b) || b > 127 => {
1955                    let start = self.pos;
1956                    // Go's scanIdentifier includes '.' and '$' in identifiers
1957                    while !self.at_end() {
1958                        let b = self.bytes[self.pos];
1959                        if b > 127 {
1960                            // Non-ASCII: check if this char is Unicode whitespace — if so, stop.
1961                            // Go's scanIdentifier stops at unicode.IsSpace chars.
1962                            let c = self.s[self.pos..].chars().next();
1963                            if c.is_some_and(char::is_whitespace) {
1964                                break;
1965                            }
1966                            self.pos += c.map_or(1, char::len_utf8);
1967                        } else if is_ident_char(b) || b == b'.' {
1968                            self.pos += 1;
1969                        } else {
1970                            break;
1971                        }
1972                    }
1973                    let token = &self.s[start..self.pos];
1974                    self.emit_identifier(token);
1975                    // If identifier ends with '.', push space so next token is separated
1976                    // (Go includes trailing '.' in identifier but still spaces next token)
1977                    if token.ends_with('.') && !self.at_end() {
1978                        self.result.push(' ');
1979                    }
1980                }
1981
1982                // Unknown: emit as-is
1983                _ => {
1984                    let c = self.s[self.pos..].chars().next().unwrap_or(' ');
1985                    if self.maybe_consume_alias_next() {
1986                        self.pos += c.len_utf8();
1987                        continue;
1988                    }
1989                    self.result.push(c);
1990                    self.pos += c.len_utf8();
1991                }
1992            }
1993        }
1994    }
1995
1996    fn finalize(mut self) -> String {
1997        // Trim trailing whitespace
1998        while self.result.ends_with(' ') {
1999            self.result.pop();
2000        }
2001        self.result
2002    }
2003}
2004
2005/// Try to match a `( ?, ?, ..., ? )` or `[ ?, ?, ..., ? ]` pattern starting at `i`.
2006/// Returns Some(k) where k is the index after the closing bracket if matched, else None.
2007const fn try_match_pure_group(bytes: &[u8], open: u8, close: u8, i: usize) -> Option<usize> {
2008    let n = bytes.len();
2009    if i >= n || bytes[i] != open {
2010        return None;
2011    }
2012    let mut k = i + 1;
2013    if k < n && bytes[k] == b' ' {
2014        k += 1;
2015    }
2016    if k >= n || bytes[k] != b'?' {
2017        return None;
2018    }
2019    k += 1;
2020    loop {
2021        if k < n && bytes[k] == b' ' {
2022            k += 1;
2023        }
2024        if k >= n {
2025            return None;
2026        }
2027        if bytes[k] == close {
2028            return Some(k + 1);
2029        }
2030        // Accept either `?, ?` or `? ?` (commas may have been stripped)
2031        if bytes[k] == b',' {
2032            k += 1;
2033            if k < n && bytes[k] == b' ' {
2034                k += 1;
2035            }
2036        }
2037        if k < n && bytes[k] == b'?' {
2038            k += 1;
2039        } else {
2040            return None;
2041        }
2042    }
2043}
2044
2045/// Collapse `( ?, ?, ..., ? )` into `( ? )`, `[ ?, ?, ..., ? ]` into `[ ? ]`,
2046/// multi-row `VALUES ( ? ) , ( ? ) , ...` into `VALUES ( ? )`, and `LIMIT ?, ?` into `LIMIT ?`.
2047fn collapse_grouped_values(s: &str, obfuscation_mode: SqlObfuscationMode) -> String {
2048    let bytes = s.as_bytes();
2049    let n = bytes.len();
2050    let mut result = String::with_capacity(n);
2051    let mut i = 0;
2052
2053    while i < n {
2054        // Try ( ?, ... )
2055        if bytes[i] == b'(' {
2056            if let Some(end) = try_match_pure_group(bytes, b'(', b')', i) {
2057                result.push_str("( ? )");
2058                i = end;
2059                continue;
2060            }
2061        }
2062        // Try [ ?, ... ]
2063        if bytes[i] == b'[' {
2064            if let Some(end) = try_match_pure_group(bytes, b'[', b']', i) {
2065                result.push_str("[ ? ]");
2066                i = end;
2067                continue;
2068            }
2069        }
2070        // Push next character correctly (multi-byte UTF-8 safe)
2071        if bytes[i] < 128 {
2072            result.push(bytes[i] as char);
2073            i += 1;
2074        } else {
2075            let c = s[i..].chars().next().unwrap_or('\u{FFFD}');
2076            result.push(c);
2077            i += c.len_utf8();
2078        }
2079    }
2080
2081    let result = collapse_multi_values(&result);
2082    #[allow(deprecated)]
2083    if matches!(obfuscation_mode, SqlObfuscationMode::Unspecified) {
2084        // FIXME: this being only collapsed on the deprecated mode is unintuitive but follows the
2085        // weird behavior of the agent's obfuscator Collapse `LIMIT ?, ?` → `LIMIT ?`
2086        // (MySQL/SQLite LIMIT offset, count syntax)
2087        collapse_limit_two_args(&result)
2088    } else {
2089        result
2090    }
2091}
2092
2093/// Collapse `VALUES ( ? ) , ( ? ) , ...` → `VALUES ( ? )`.
2094/// Also handles comma-less groups `VALUES ( ? ) ( ? )` (when commas were stripped by placeholder
2095/// logic).
2096fn collapse_multi_values(s: &str) -> String {
2097    // Pattern: "VALUES ( ? )" followed by one or more " , ( ? )" or " ( ? )" groups
2098    let mut result = String::with_capacity(s.len());
2099    let mut remaining = s;
2100
2101    while let Some(c) = remaining.chars().next() {
2102        const VALUES_KW: &str = "VALUES";
2103        const VALUES_TAIL: &str = " ( ? )";
2104        const VALUES_FULL: &str = "VALUES ( ? )";
2105
2106        // Match "VALUES" case-insensitively, and then match the exact tail " ( ? )"
2107        let matches_values_pattern = remaining.get(..VALUES_FULL.len()).is_some_and(|head| {
2108            head.get(..VALUES_KW.len())
2109                .is_some_and(|kw| kw.eq_ignore_ascii_case(VALUES_KW))
2110                && head
2111                    .get(VALUES_KW.len()..)
2112                    .is_some_and(|tail| tail == VALUES_TAIL)
2113        });
2114
2115        if matches_values_pattern {
2116            // Preceding context: must be start or space/'(' or '\n'
2117            let prev_ok =
2118                result.is_empty() || matches!(result.chars().last(), Some(' ' | '(' | '\n'));
2119
2120            if prev_ok {
2121                // Keep the original casing as it appeared in `remaining`
2122                result.push_str(&remaining[..VALUES_FULL.len()]);
2123                remaining = &remaining[VALUES_FULL.len()..];
2124
2125                // Consume trailing groups
2126                loop {
2127                    if let Some(rest) = remaining.strip_prefix(", ( ? )") {
2128                        remaining = rest;
2129                    } else if let Some(rest) = remaining.strip_prefix(" ( ? )") {
2130                        remaining = rest;
2131                    } else if let Some(rest) = remaining.strip_prefix(" ()") {
2132                        remaining = rest;
2133                    } else {
2134                        break;
2135                    }
2136                }
2137                continue;
2138            }
2139        }
2140
2141        result.push(c);
2142        remaining = &remaining[c.len_utf8()..];
2143    }
2144    result
2145}
2146
2147/// Collapse `LIMIT ?, ?` → `LIMIT ?`
2148fn collapse_limit_two_args(s: &str) -> String {
2149    const PREFIX: &[u8] = b"LIMIT ?";
2150
2151    // Scan for "LIMIT ?, ?" pattern (all ASCII keywords, UTF-8 safe via char iteration)
2152    let mut result = String::with_capacity(s.len());
2153    let mut remaining = s;
2154
2155    while !remaining.is_empty() {
2156        // Check for LIMIT (case-insensitive) + " ?, ?" or " ? ?"
2157        if remaining.len() >= 9 {
2158            let rb = remaining.as_bytes();
2159            if rb[..PREFIX.len()].eq_ignore_ascii_case(PREFIX) {
2160                // Check " ?, ?" or " ? ?"
2161                let skip =
2162                    if remaining.len() >= 10 && rb[7] == b',' && rb[8] == b' ' && rb[9] == b'?' {
2163                        Some(10) // "LIMIT ?, ?"
2164                    } else if rb[7] == b' ' && rb[8] == b'?' {
2165                        Some(9) // "LIMIT ? ?" (comma already stripped)
2166                    } else {
2167                        None
2168                    };
2169                if let Some(skip_len) = skip {
2170                    // Word boundary: previous char in result should be space or start
2171                    let prev_ok = result.is_empty()
2172                        || matches!(result.as_bytes().last(), Some(b' ' | b'(' | b'\n'));
2173                    if prev_ok {
2174                        result.push_str(&remaining[..7]); // "LIMIT ?"
2175                        remaining = &remaining[skip_len..];
2176                        continue;
2177                    }
2178                }
2179            }
2180        }
2181        let c = remaining.chars().next().unwrap_or(' ');
2182        result.push(c);
2183        remaining = &remaining[c.len_utf8()..];
2184    }
2185    result
2186}
2187
2188/// Obfuscates a SQL string using a proper tokenizer.
2189#[must_use]
2190pub fn obfuscate_sql(s: &str, config: &SqlObfuscateConfig, dbms: DbmsKind) -> String {
2191    if s.is_empty() {
2192        return String::new();
2193    }
2194    let mut tokenizer = Tokenizer::new(s, config, dbms);
2195    tokenizer.process();
2196    let raw = tokenizer.finalize();
2197    // collapse_grouped_values applies in legacy mode and obfuscate_and_normalize mode.
2198    // In obfuscate_only and normalize_only modes, values are NOT collapsed.
2199    #[allow(deprecated)]
2200    let should_collapse = matches!(
2201        config.obfuscation_mode,
2202        SqlObfuscationMode::Unspecified | SqlObfuscationMode::ObfuscateAndNormalize
2203    );
2204    if should_collapse {
2205        collapse_grouped_values(&raw, config.obfuscation_mode)
2206    } else {
2207        raw
2208    }
2209}
2210
2211/// Obfuscates a SQL string with default configuration.
2212#[must_use]
2213pub fn obfuscate_sql_string(s: &str) -> String {
2214    obfuscate_sql(s, &SqlObfuscateConfig::default(), DbmsKind::Generic)
2215}
2216
2217/// SQL obfuscation with Go-compatible whitespace normalization for use in JSON plan obfuscation.
2218/// Applies `obfuscate_sql_string` then additional normalizations for JSON plan SQL.
2219// FIXME: remove these tiny wrappers they provide no value, keep the public api 1 function which
2220// takes a config
2221#[must_use]
2222pub fn obfuscate_sql_string_normalized(s: &str) -> String {
2223    let obfuscated = obfuscate_sql_string(s);
2224    normalize_plan_sql(&obfuscated)
2225}
2226
2227fn normalize_plan_sql(s: &str) -> String {
2228    let mut result = String::with_capacity(s.len());
2229    let mut chars = s.chars().peekable();
2230
2231    while let Some(c) = chars.next() {
2232        match c {
2233            '`' => {
2234                // Strip backticks: collect identifier content up to closing backtick
2235                let identifier: String = chars.by_ref().take_while(|&c| c != '`').collect();
2236                result.push_str(&identifier);
2237
2238                // If followed by `.` then another backtick identifier, replace `.` with ` . `
2239                if chars.peek() == Some(&'.') {
2240                    chars.next(); // consume `.`
2241                    result.push_str(if chars.peek() == Some(&'`') {
2242                        " . "
2243                    } else {
2244                        "."
2245                    });
2246                }
2247            }
2248            '(' => {
2249                result.push('(');
2250                if chars.peek().is_some_and(|&c| c != ' ') {
2251                    result.push(' ');
2252                }
2253            }
2254            ')' => {
2255                if result.as_bytes().last().is_some_and(|&b| b != b' ') {
2256                    result.push(' ');
2257                }
2258                result.push(')');
2259            }
2260            ':' if chars.peek() == Some(&':') => {
2261                chars.next(); // consume second `:`
2262                if result.as_bytes().last().is_some_and(|&b| b != b' ') {
2263                    result.push(' ');
2264                }
2265                result.push_str("::");
2266                if chars.peek().is_some_and(|&c| c != ' ') {
2267                    result.push(' ');
2268                }
2269            }
2270            _ => result.push(c),
2271        }
2272    }
2273    result
2274}
2275
2276#[cfg(test)]
2277mod tests {
2278    use super::{DbmsKind, SqlObfuscateConfig, SqlObfuscationMode};
2279    use std::fmt::Write;
2280
2281    #[test]
2282    fn test_sql_obfuscation() {
2283        let mut panic = None;
2284        let err = CASES
2285            .iter()
2286            .enumerate()
2287            .filter_map(|(i, (input, output))| {
2288                let err =
2289                    match std::panic::catch_unwind(|| test_sql_obfuscation_case(input, output)) {
2290                        Ok(r) => r,
2291                        Err(p) => {
2292                            panic = Some(p);
2293                            eprintln!("panicked case {i}\n\tinput: {input}\n\n");
2294                            return None;
2295                        }
2296                    }
2297                    .err()?;
2298                Some(format!("failed case {i}\n\terr: {err}\n"))
2299            })
2300            .collect::<String>();
2301        if !err.is_empty() {
2302            if panic.is_none() {
2303                panic!("{err}")
2304            } else {
2305                eprintln!("{err}");
2306            }
2307        }
2308        if let Some(p) = panic {
2309            std::panic::resume_unwind(p);
2310        }
2311    }
2312
2313    fn test_sql_obfuscation_case(input: &str, output: &str) -> anyhow::Result<()> {
2314        let got = super::obfuscate_sql_string(input);
2315        if output != got {
2316            anyhow::bail!("expected {output:?}\n\tgot:      {got:?}")
2317        }
2318        Ok(())
2319    }
2320
2321    #[test]
2322    fn test_sql_obfuscation_normalized() {
2323        let mut panic = None;
2324        let err = NORMALIZED_CASES
2325            .iter()
2326            .enumerate()
2327            .filter_map(|(i, (input, output))| {
2328                let err = match std::panic::catch_unwind(|| {
2329                    test_sql_obfuscation_normalized_case(input, output)
2330                }) {
2331                    Ok(r) => r,
2332                    Err(p) => {
2333                        panic = Some(p);
2334                        eprintln!("panicked normalized case {i}\n\tinput: {input}\n\n");
2335                        return None;
2336                    }
2337                }
2338                .err()?;
2339                Some(format!("failed normalized case {i}\n\terr: {err}\n"))
2340            })
2341            .collect::<String>();
2342        if !err.is_empty() {
2343            if panic.is_none() {
2344                panic!("{err}")
2345            } else {
2346                eprintln!("{err}");
2347            }
2348        }
2349        if let Some(p) = panic {
2350            std::panic::resume_unwind(p);
2351        }
2352    }
2353
2354    fn test_sql_obfuscation_normalized_case(input: &str, output: &str) -> anyhow::Result<()> {
2355        let got = super::obfuscate_sql_string_normalized(input);
2356        if output != got {
2357            anyhow::bail!("expected {output:?}\n\tgot:      {got:?}")
2358        }
2359        Ok(())
2360    }
2361
2362    #[test]
2363    fn test_keep_identifier_quotation() {
2364        let config = SqlObfuscateConfig {
2365            keep_identifier_quotation: true,
2366            ..Default::default()
2367        };
2368        let got = super::obfuscate_sql(
2369            r#"SELECT * FROM "users" WHERE id = 1 AND name = 'test'"#,
2370            &config,
2371            DbmsKind::Generic,
2372        );
2373        // In old tokenizer mode, keep_identifier_quotation is ignored (Go does too).
2374        let expected = "SELECT * FROM users WHERE id = ? AND name = ?";
2375        assert_eq!(got, expected, "keep_identifier_quotation: got {got:?}");
2376    }
2377
2378    #[test]
2379    fn test_remove_space_between_parentheses() {
2380        let config = SqlObfuscateConfig {
2381            remove_space_between_parentheses: true,
2382            ..Default::default()
2383        };
2384        let got = super::obfuscate_sql(
2385            "SELECT * FROM users WHERE id = ? AND (name = 'test' OR name = 'test2')",
2386            &config,
2387            DbmsKind::Generic,
2388        );
2389        // In old-tokenizer mode, Go ignores remove_space_between_parentheses and always adds spaces
2390        let expected = "SELECT * FROM users WHERE id = ? AND ( name = ? OR name = ? )";
2391        assert_eq!(
2392            got, expected,
2393            "remove_space_between_parentheses: got {got:?}"
2394        );
2395    }
2396
2397    #[test]
2398    fn test_keep_positional_parameter() {
2399        // When keep_positional_parameter=true, $1/$2 should be kept as-is
2400        let config = SqlObfuscateConfig {
2401            keep_positional_parameter: true,
2402            ..Default::default()
2403        };
2404        let got = super::obfuscate_sql(
2405            "SELECT * FROM users WHERE id = ? AND name = $1 and id = $2",
2406            &config,
2407            DbmsKind::Generic,
2408        );
2409        // In old-tokenizer mode (obfuscation_mode=""), positional params are always replaced
2410        // regardless of keep_positional_parameter (matches Go's old tokenizer behavior).
2411        let expected = "SELECT * FROM users WHERE id = ? AND name = ? and id = ?";
2412        assert_eq!(
2413            got, expected,
2414            "keep_positional_parameter: got {got:?}, expected {expected:?}"
2415        );
2416    }
2417
2418    const NORMALIZED_CASES: &[(&str, &str)] = &[
2419        // 'value'::type fix (in obfuscate_sql_string)
2420        ("'60'::double precision", "? :: double precision"),
2421        ("'dogfood'::text", "? :: text"),
2422        ("'15531'::tid", "? :: tid"),
2423        ("(query <> 'dogfood'::text)", "( query <> ? :: text )"),
2424        // normalize_plan_sql — parens spacing
2425        ("(foo != ?)", "( foo != ? )"),
2426        ("((a >= ?) AND (b < ?))", "( ( a >= ? ) AND ( b < ? ) )"),
2427        // normalize_plan_sql — :: spacing
2428        ("?::double precision", "? :: double precision"),
2429        ("(query <> ?::text)", "( query <> ? :: text )"),
2430        // normalize_plan_sql — backtick stripping
2431        ("`id`", "id"),
2432        (
2433            "(`sbtest`.`sbtest1`.`id` between ? and ?)",
2434            "( sbtest . sbtest1 . id between ? and ? )",
2435        ),
2436        // full pipeline (obfuscate_sql_string_normalized)
2437        (
2438            "(`sbtest`.`sbtest1`.`id` between 5016 and 5115)",
2439            "( sbtest . sbtest1 . id between ? and ? )",
2440        ),
2441        ("(query <> 'dogfood'::text)", "( query <> ? :: text )"),
2442        ("'60'::double precision", "? :: double precision"),
2443    ];
2444
2445    const CASES: &[(&str, &str)] = &[
2446        ("", ""),
2447        ("   ", ""),
2448        ("         ", ""),
2449        ("罿", "罿"),
2450        ("罿潯", "罿潯"),
2451        ("罿潯罿潯罿潯罿潯罿潯", "罿潯罿潯罿潯罿潯罿潯"),
2452        ("'abc1287681964'", "?"),
2453        ("-- comment", ""),
2454        ("---", ""),
2455        ("1 - 2", "? - ?"),
2456        (
2457            "SELECT * FROM TABLE WHERE userId = 'abc1287681964'",
2458            "SELECT * FROM TABLE WHERE userId = ?",
2459        ),
2460        // Standard SQL uses '' to escape quotes, not backslash
2461        (
2462            "SELECT * FROM TABLE WHERE userId = 'it''s a string'",
2463            "SELECT * FROM TABLE WHERE userId = ?",
2464        ),
2465        (
2466            "SELECT * FROM TABLE WHERE userId IN ('a', 'b', 'c')",
2467            "SELECT * FROM TABLE WHERE userId IN ( ? )",
2468        ),
2469        (
2470            "SELECT * FROM TABLE WHERE userId = 'abc1287681964' ORDER BY FOO DESC",
2471            "SELECT * FROM TABLE WHERE userId = ? ORDER BY FOO DESC",
2472        ),
2473        // Backslash followed by ' at a SQL word boundary (space follows): string closes there
2474        // 'backslash\' closes at the ' after \, because ' is followed by space (SQL boundary)
2475        (
2476            "SELECT * FROM foo LEFT JOIN bar ON 'backslash\\' = foo.b WHERE foo.name = 'String'",
2477            "SELECT * FROM foo LEFT JOIN bar ON ? = foo.b WHERE foo.name = ?",
2478        ),
2479        (
2480            "SELECT * FROM foo LEFT JOIN bar ON 'backslash\\' = foo.b LEFT JOIN bar2 ON 'backslash2\\' = foo.b2 WHERE foo.name = 'String'",
2481            "SELECT * FROM foo LEFT JOIN bar ON ? = foo.b LEFT JOIN bar2 ON ? = foo.b2 WHERE foo.name = ?",
2482        ),
2483        // Backslash followed by ' before more string content (alphanumeric follows): acts as escape
2484        // 'embedded \'quote\' in string' is ONE string because ' after \ is followed by 'q'
2485        (
2486            "SELECT * FROM foo LEFT JOIN bar ON 'embedded \\'quote\\' in string' = foo.b WHERE foo.name = 'String'",
2487            "SELECT * FROM foo LEFT JOIN bar ON ? = foo.b WHERE foo.name = ?",
2488        ),
2489        (
2490            "SELECT * FROM TABLE JOIN SOMETHING ON TABLE.foo = SOMETHING.bar",
2491            "SELECT * FROM TABLE JOIN SOMETHING ON TABLE.foo = SOMETHING.bar",
2492        ),
2493        (
2494            "CREATE TABLE \"VALUE\"",
2495            "CREATE TABLE VALUE",
2496        ),
2497        (
2498            "INSERT INTO \"VALUE\" (\"column\") VALUES (\'ljahklshdlKASH\')",
2499            "INSERT INTO VALUE ( column ) VALUES ( ? )",
2500        ),
2501        (
2502            "INSERT INTO \"VALUE\" (\"col1\",\"col2\",\"col3\") VALUES (\'blah\',12983,X'ff')",
2503            "INSERT INTO VALUE ( col1, col2, col3 ) VALUES ( ? )",
2504        ),
2505        (
2506            "INSERT INTO \"VALUE\" (\"col1\", \"col2\", \"col3\") VALUES (\'blah\',12983,X'ff')",
2507            "INSERT INTO VALUE ( col1, col2, col3 ) VALUES ( ? )",
2508        ),
2509        (
2510            "INSERT INTO VALUE (col1,col2,col3) VALUES (\'blah\',12983,X'ff')",
2511            "INSERT INTO VALUE ( col1, col2, col3 ) VALUES ( ? )",
2512        ),
2513        (
2514            "INSERT INTO VALUE (col1,col2,col3) VALUES (12983,X'ff',\'blah\')",
2515            "INSERT INTO VALUE ( col1, col2, col3 ) VALUES ( ? )",
2516        ),
2517        (
2518            "INSERT INTO VALUE (col1,col2,col3) VALUES (X'ff',\'blah\',12983)",
2519            "INSERT INTO VALUE ( col1, col2, col3 ) VALUES ( ? )",
2520        ),
2521        (
2522            "INSERT INTO VALUE (col1,col2,col3) VALUES ('a',\'b\',1)",
2523            "INSERT INTO VALUE ( col1, col2, col3 ) VALUES ( ? )",
2524        ),
2525        (
2526            "INSERT INTO VALUE (col1, col2, col3) VALUES ('a',\'b\',1)",
2527            "INSERT INTO VALUE ( col1, col2, col3 ) VALUES ( ? )",
2528        ),
2529        (
2530            "INSERT INTO VALUE ( col1, col2, col3 ) VALUES ('a',\'b\',1)",
2531            "INSERT INTO VALUE ( col1, col2, col3 ) VALUES ( ? )",
2532        ),
2533        (
2534            "INSERT INTO VALUE (col1,col2,col3) VALUES ('a', \'b\' ,1)",
2535            "INSERT INTO VALUE ( col1, col2, col3 ) VALUES ( ? )",
2536        ),
2537        (
2538            "INSERT INTO VALUE (col1, col2, col3) VALUES ('a', \'b\', 1)",
2539            "INSERT INTO VALUE ( col1, col2, col3 ) VALUES ( ? )",
2540        ),
2541        (
2542            "INSERT INTO VALUE ( col1, col2, col3 ) VALUES ('a', \'b\', 1)",
2543            "INSERT INTO VALUE ( col1, col2, col3 ) VALUES ( ? )",
2544        ),
2545        (
2546            "INSERT INTO VALUE (col1,col2,col3) VALUES (X'ff',\'罿潯罿潯罿潯罿潯罿潯\',12983)",
2547            "INSERT INTO VALUE ( col1, col2, col3 ) VALUES ( ? )",
2548        ),
2549        (
2550            "INSERT INTO VALUE (col1,col2,col3) VALUES (X'ff',\'罿\',12983)",
2551            "INSERT INTO VALUE ( col1, col2, col3 ) VALUES ( ? )",
2552        ),
2553        // AS resets groupFilter: comma after alias IS kept (verified against Go)
2554        (
2555            "SELECT 3 AS NUCLEUS_TYPE,A0.ID,A0.\"NAME\" FROM \"VALUE\" A0",
2556            "SELECT ?, A0.ID, A0. NAME FROM VALUE A0",
2557        ),
2558        (
2559            "SELECT COUNT(*) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 > .9999",
2560            "SELECT COUNT ( * ) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 > ?",
2561        ),
2562        (
2563            "SELECT COUNT(*) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 > 0.9999",
2564            "SELECT COUNT ( * ) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 > ?",
2565        ),
2566        (
2567            "SELECT COUNT(*) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 > -0.9999",
2568            "SELECT COUNT ( * ) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 > ?",
2569        ),
2570        (
2571            "SELECT COUNT(*) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 > -1e6",
2572            "SELECT COUNT ( * ) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 > ?",
2573        ),
2574        (
2575            "SELECT COUNT(*) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 > +1e6",
2576            "SELECT COUNT ( * ) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 > + ?",
2577        ),
2578        (
2579            "SELECT COUNT(*) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 > +255",
2580            "SELECT COUNT ( * ) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 > + ?",
2581        ),
2582        (
2583            "SELECT COUNT(*) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 > +6.34F",
2584            "SELECT COUNT ( * ) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 > + ? F",
2585        ),
2586        (
2587            "SELECT COUNT(*) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 > +6f",
2588            "SELECT COUNT ( * ) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 > + ? f",
2589        ),
2590        (
2591            "SELECT COUNT(*) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 > +0.5D",
2592            "SELECT COUNT ( * ) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 > + ? D",
2593        ),
2594        (
2595            "SELECT COUNT(*) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 > -1d",
2596            "SELECT COUNT ( * ) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 > ? d",
2597        ),
2598        (
2599            "SELECT COUNT(*) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 > x'ff'",
2600            "SELECT COUNT ( * ) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 > ?",
2601        ),
2602        (
2603            "SELECT COUNT(*) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 > X'ff'",
2604            "SELECT COUNT ( * ) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 > ?",
2605        ),
2606        (
2607            "SELECT COUNT(*) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 > 0xff",
2608            "SELECT COUNT ( * ) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 > ?",
2609        ),
2610        (
2611            "SELECT COUNT(*) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 <> \'\'",
2612            "SELECT COUNT ( * ) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 <> ?",
2613        ),
2614        (
2615            "SELECT COUNT(*) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 <> \' \'",
2616            "SELECT COUNT ( * ) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 <> ?",
2617        ),
2618        (
2619            "SELECT COUNT(*) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 <> \'  \'",
2620            "SELECT COUNT ( * ) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 <> ?",
2621        ),
2622        // Standard SQL strings with spaces and regular content
2623        (
2624            "SELECT COUNT(*) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 <> ' x '",
2625            "SELECT COUNT ( * ) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 <> ?",
2626        ),
2627        (
2628            "SELECT COUNT(*) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 <> ' x x'",
2629            "SELECT COUNT ( * ) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 <> ?",
2630        ),
2631        (
2632            "SELECT COUNT(*) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 <> \'5,123\'",
2633            "SELECT COUNT ( * ) FROM TABLE_1 JOIN table_2 ON TABLE_1.foo = table_2.bar where col1 <> ?",
2634        ),
2635        // comma after ? is stripped: NOT NULL, next_col → NOT ? next_col
2636        (
2637            "CREATE TABLE S_H2 (id INTEGER not NULL, PRIMARY KEY ( id ))",
2638            "CREATE TABLE S_H2 ( id INTEGER not ? PRIMARY KEY ( id ) )",
2639        ),
2640        (
2641            "CREATE TABLE S_H2 ( id INTEGER not NULL, PRIMARY KEY ( id ) )",
2642            "CREATE TABLE S_H2 ( id INTEGER not ? PRIMARY KEY ( id ) )",
2643        ),
2644        (
2645            "SELECT * FROM TABLE WHERE name = 'O''Brady'",
2646            "SELECT * FROM TABLE WHERE name = ?",
2647        ),
2648        (
2649            "INSERT INTO visits VALUES (2, 8, '2013-01-02', 'rabies shot')",
2650            "INSERT INTO visits VALUES ( ? )",
2651        ),
2652        (
2653            "SELECT * FROM TABLE WHERE userId = ',' and foo=foo.bar",
2654            "SELECT * FROM TABLE WHERE userId = ? and foo = foo.bar",
2655        ),
2656        (
2657            "SELECT * FROM TABLE WHERE userId =     ','||foo.bar",
2658            "SELECT * FROM TABLE WHERE userId = ? | | foo.bar",
2659        ),
2660        // :named bind params kept as-is
2661        (
2662            "SELECT * FROM t WHERE y IN (:protocols) AND x IN (:sites)",
2663            "SELECT * FROM t WHERE y IN ( :protocols ) AND x IN ( :sites )",
2664        ),
2665        // multi-row VALUES collapse (quantizer_33)
2666        (
2667            "INSERT INTO user (id, username) VALUES ('Fred','Smith'), ('John','Smith'), ('Michael','Smith'), ('Robert','Smith');",
2668            "INSERT INTO user ( id, username ) VALUES ( ? )",
2669        ),
2670        // backtick identifier with regular ident after dot (quantizer_43)
2671        (
2672            "INSERT INTO `qual-aa`.issues (alert0, alert1) VALUES (NULL, NULL)",
2673            "INSERT INTO qual-aa . issues ( alert0, alert1 ) VALUES ( ? )",
2674        ),
2675        // !+2 sign handling: + after ! should be an operator (table_finder_23)
2676        (
2677            "select !+2",
2678            "select ! + ?",
2679        ),
2680        // keep_positional_parameter handled in test_sql_obfuscation_config below
2681
2682        // 5*s1: multiplication, not sign prefix (quantizer_49 style)
2683        (
2684            "SELECT 5*s1 FROM t4",
2685            "SELECT ? * s1 FROM t4",
2686        ),
2687        (
2688            "(SELECT 5*s1 FROM t4 UNION SELECT 77 FROM t5)",
2689            "( SELECT ? * s1 FROM t4 UNION SELECT ? FROM t5 )",
2690        ),
2691        // Full quantizer_49 relevant fragment (ROW with = subquery)
2692        (
2693            "WHERE ROW(5*t2.s1,77)=(SELECT 5*s1 FROM t4 UNION SELECT 77 FROM (SELECT * FROM t5))",
2694            "WHERE ROW ( ? * t2.s1, ? ) = ( SELECT ? * s1 FROM t4 UNION SELECT ? FROM ( SELECT * FROM t5 ) )",
2695        ),
2696        // comma after ? is stripped (quantizer_10 style)
2697        (
2698            "UPDATE user_dash_pref SET json_prefs = %(json_prefs)s, modified = '2015-08-27' WHERE user_id = %(user_id)s AND url = %(url)s",
2699            "UPDATE user_dash_pref SET json_prefs = ? modified = ? WHERE user_id = ? AND url = ?",
2700        ),
2701        // comma after ? in SET list (metadata_create_trigger style)
2702        (
2703            "UPDATE t SET a = 1, b = 2, c = 3",
2704            "UPDATE t SET a = ? b = ? c = ?",
2705        ),
2706        // comma after ? in function call (quantizer_81 style): comma after first ? arg stripped
2707        (
2708            "SELECT set_config('foo', bar, FALSE)",
2709            "SELECT set_config ( ? bar, ? )",
2710        ),
2711        // fuzzing_792557810: colon followed by unicode letter should not add space
2712        (":ჸ", ":ჸ"),
2713        // fuzzing_1113621604: % followed by unicode letter is a format parameter → ?
2714        ("%ჸ", "?"),
2715        // fuzzing_4250509562: % followed by any ASCII letter is a format parameter → ?
2716        ("%C", "?"),
2717        // fuzzing_1492599371: standalone dot followed by unicode ident needs space
2718        (".ჸ", ". ჸ"),
2719        // sql_fuzzing: 0!(2 grouping — operator resets lp, so '2' after '(' gets '?'
2720        ("0!(2", "? ! ( ?"),
2721        // sql_fuzzing_3326675327: '(' does NOT reset lp — '$0' grouped with leading '?'
2722        ("0(($0", "? ( ("),
2723        // fuzzing_4233627642: digit followed by letter suffix — letter is separate IDENT
2724        ("0D", "? D"),
2725        // sql_fuzzing_4064530249: @ followed by unicode ident → no space (bind param)
2726        ("@ᏤᏤ", "@ᏤᏤ"),
2727        // fuzzing_4138960753: unicode ident immediately followed by * is one token
2728        ("ჸ*", "ჸ*"),
2729        // sql_fuzzing: standalone .* → ". *" but table.* → "table.*"
2730        (".*", ". *"),
2731        ("table.*", "table.*"),
2732        // fuzzing test: ( followed by unicode ident should have space
2733        ("(ჷ", "( ჷ"),
2734        ("2%$2", "? % ?"),
2735    ];
2736
2737    #[test]
2738    fn test_normalize_only() {
2739        let config = SqlObfuscateConfig {
2740            obfuscation_mode: SqlObfuscationMode::NormalizeOnly,
2741            ..Default::default()
2742        };
2743        let cases = &[
2744            // Simple: keep numbers as-is
2745            (
2746                "SELECT * FROM users WHERE id = 1",
2747                "SELECT * FROM users WHERE id = 1",
2748            ),
2749            // Keep strings as-is
2750            (
2751                "SELECT * FROM users WHERE id = 1 AND name = 'test'",
2752                "SELECT * FROM users WHERE id = 1 AND name = 'test'",
2753            ),
2754            // Strip comments, normalize whitespace
2755            (
2756                "-- comment\n/* comment */\nSELECT id as id, name as n FROM users123 WHERE id in (1,2,3)",
2757                "SELECT id as id, name as n FROM users123 WHERE id in ( 1, 2, 3 )",
2758            ),
2759            // WITH CTE: keep AS
2760            (
2761                "WITH users AS (SELECT * FROM people) SELECT * FROM users",
2762                "WITH users AS ( SELECT * FROM people ) SELECT * FROM users",
2763            ),
2764            // Keep positional params as-is
2765            (
2766                "SELECT * FROM users WHERE id = 1 AND address = $1 and id = $2 AND deleted IS NULL AND active is TRUE",
2767                "SELECT * FROM users WHERE id = 1 AND address = $1 and id = $2 AND deleted IS NULL AND active is TRUE",
2768            ),
2769            // keep_trailing_semicolon ignored — semicolon stripped in normalize mode (same as obfuscate)
2770            // Actually in normalize mode: keep semicolon when keep_trailing_semicolon=true
2771            // Here with default (false): strip semicolon
2772            (
2773                "SELECT * FROM users WHERE id = 1;",
2774                "SELECT * FROM users WHERE id = 1",
2775            ),
2776        ];
2777        for (input, expected) in cases {
2778            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
2779            assert_eq!(got, *expected, "normalize_only input={input:?}");
2780        }
2781    }
2782
2783    #[test]
2784    fn test_normalize_only_keep_trailing_semi() {
2785        let config = SqlObfuscateConfig {
2786            obfuscation_mode: SqlObfuscationMode::NormalizeOnly,
2787            keep_trailing_semicolon: true,
2788            ..Default::default()
2789        };
2790        let got = super::obfuscate_sql(
2791            "SELECT * FROM users WHERE id = 1 AND name = 'test';",
2792            &config,
2793            DbmsKind::Generic,
2794        );
2795        let expected = "SELECT * FROM users WHERE id = 1 AND name = 'test';";
2796        assert_eq!(
2797            got, expected,
2798            "normalize_only+keep_trailing_semicolon: {got:?}"
2799        );
2800    }
2801
2802    #[test]
2803    fn test_normalize_only_keep_identifier_quotation() {
2804        let config = SqlObfuscateConfig {
2805            obfuscation_mode: SqlObfuscationMode::NormalizeOnly,
2806            keep_identifier_quotation: true,
2807            ..Default::default()
2808        };
2809        let got = super::obfuscate_sql(
2810            r#"SELECT * FROM "users" WHERE id = 1 AND name = 'test'"#,
2811            &config,
2812            DbmsKind::Generic,
2813        );
2814        let expected = r#"SELECT * FROM "users" WHERE id = 1 AND name = 'test'"#;
2815        assert_eq!(
2816            got, expected,
2817            "normalize_only+keep_identifier_quotation: {got:?}"
2818        );
2819    }
2820
2821    #[test]
2822    fn test_with_cte_stripping() {
2823        // In legacy mode (obfuscation_mode=""), WITH T1 AS (SELECT...) → WITH T1 SELECT...
2824        let config = SqlObfuscateConfig::default();
2825        let cases = &[
2826            // Single CTE - strip AS and opening paren, keep closing )
2827            (
2828                "WITH sales AS (SELECT x FROM t WHERE id = 1) SELECT * FROM sales",
2829                "WITH sales SELECT x FROM t WHERE id = ? ) SELECT * FROM sales",
2830            ),
2831            // Two CTEs - comma between CTEs stripped too
2832            (
2833                "WITH T1 AS (SELECT a FROM t1 WHERE id = 1), T2 AS (SELECT b FROM t2) SELECT * FROM T1",
2834                "WITH T1 SELECT a FROM t1 WHERE id = ? ) T2 SELECT b FROM t2 ) SELECT * FROM T1",
2835            ),
2836        ];
2837        for (input, expected) in cases {
2838            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
2839            assert_eq!(got, *expected, "with_cte_stripping input={input:?}");
2840        }
2841    }
2842
2843    #[test]
2844    fn test_double_quoted_string_value_quantize() {
2845        // Double-quoted strings in value context (after =) should be quantized
2846        let config = SqlObfuscateConfig::default();
2847        let cases = &[
2848            // After = in SET clause
2849            (
2850                r#"update Orders set created = "2019-05-24 00:26:17", gross = 30.28"#,
2851                "update Orders set created = ? gross = ?",
2852            ),
2853            // After = in SET clause, identifier-like value
2854            (
2855                r#"update Orders set payment_type = "eventbrite""#,
2856                "update Orders set payment_type = ?",
2857            ),
2858            // Table identifier (after FROM) — keep
2859            (
2860                r#"SELECT * FROM "users" WHERE id = 1"#,
2861                r"SELECT * FROM users WHERE id = ?",
2862            ),
2863        ];
2864        for (input, expected) in cases {
2865            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
2866            assert_eq!(got, *expected, "double_quoted_value input={input:?}");
2867        }
2868    }
2869
2870    #[test]
2871    fn test_normalize_only_dollar_func() {
2872        // In normalize_only mode, dollar-quoted strings are normalized (not quantized)
2873        let config = SqlObfuscateConfig {
2874            obfuscation_mode: SqlObfuscationMode::NormalizeOnly,
2875            ..Default::default()
2876        };
2877        let got = super::obfuscate_sql(
2878            "SELECT $func$INSERT INTO table VALUES ('a', 1, 2)$func$ FROM users",
2879            &config,
2880            DbmsKind::Generic,
2881        );
2882        let expected = "SELECT $func$INSERT INTO table VALUES ( 'a', 1, 2 )$func$ FROM users";
2883        assert_eq!(got, expected, "normalize_only dollar_func: {got:?}");
2884    }
2885
2886    #[test]
2887    fn test_dollar_quoted_func_trivial_collapse() {
2888        // When dollar_quoted_func=true and inner content obfuscates to a single ?, collapse to ?
2889        let config = SqlObfuscateConfig {
2890            dollar_quoted_func: true,
2891            replace_digits: true,
2892            ..Default::default()
2893        };
2894        let got = super::obfuscate_sql(
2895            "SELECT * FROM users123 WHERE id = $tag$1$tag$",
2896            &config,
2897            DbmsKind::Generic,
2898        );
2899        let expected = "SELECT * FROM users? WHERE id = ?";
2900        assert_eq!(
2901            got, expected,
2902            "dollar_quoted_func trivial collapse: {got:?}"
2903        );
2904    }
2905
2906    #[test]
2907    fn test_obfuscate_only_keeps_quotes_and_semi() {
2908        // In obfuscate_only mode: keep double-quoted identifiers, keep $?, keep trailing ;
2909        let config = SqlObfuscateConfig {
2910            obfuscation_mode: SqlObfuscationMode::ObfuscateOnly,
2911            ..Default::default()
2912        };
2913        let got = super::obfuscate_sql(
2914            r#"SELECT "table"."field" FROM "table" WHERE "table"."otherfield" = $? AND "table"."thirdfield" = $?;"#,
2915            &config,
2916            DbmsKind::Generic,
2917        );
2918        let expected = r#"SELECT "table"."field" FROM "table" WHERE "table"."otherfield" = $? AND "table"."thirdfield" = $?;"#;
2919        assert_eq!(got, expected, "obfuscate_only keeps quotes/$/semi: {got:?}");
2920    }
2921
2922    #[test]
2923    fn test_obfuscate_only_dollar_quoted_func_no_collapse() {
2924        // In obfuscate_only+dollar_quoted_func: VALUES inside func are NOT collapsed
2925        let config = SqlObfuscateConfig {
2926            obfuscation_mode: SqlObfuscationMode::ObfuscateOnly,
2927            dollar_quoted_func: true,
2928            ..Default::default()
2929        };
2930        let got = super::obfuscate_sql(
2931            "SELECT $func$INSERT INTO table VALUES ('a', 1, 2)$func$ FROM users",
2932            &config,
2933            DbmsKind::Generic,
2934        );
2935        let expected = "SELECT $func$INSERT INTO table VALUES (?, ?, ?)$func$ FROM users";
2936        assert_eq!(
2937            got, expected,
2938            "obfuscate_only dollar_quoted_func no collapse: {got:?}"
2939        );
2940    }
2941
2942    #[test]
2943    fn test_normalize_only_procedure() {
2944        let config = SqlObfuscateConfig {
2945            obfuscation_mode: SqlObfuscationMode::NormalizeOnly,
2946            ..Default::default()
2947        };
2948        let got = super::obfuscate_sql(
2949            "CREATE PROCEDURE TestProc AS BEGIN UPDATE users SET name = 'test' WHERE id = 1 END",
2950            &config,
2951            DbmsKind::Generic,
2952        );
2953        let expected =
2954            "CREATE PROCEDURE TestProc AS BEGIN UPDATE users SET name = 'test' WHERE id = 1 END";
2955        assert_eq!(got, expected, "normalize_only+procedure: {got:?}");
2956    }
2957
2958    #[test]
2959    fn test_q41() {
2960        let config = SqlObfuscateConfig::default();
2961        let input = "SELECT * FROM public.table ( array [ ROW ( array [ 'magic', 'foo',";
2962        // First check raw (pre-collapse) output
2963        let mut tok = super::Tokenizer::new(input, &config, DbmsKind::Generic);
2964        tok.process();
2965        let raw = tok.finalize();
2966        eprintln!("RAW: {raw:?}");
2967        let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
2968        let expected = "SELECT * FROM public.table ( array [ ROW ( array [ ?";
2969        assert_eq!(got, expected, "q41: {got:?}");
2970    }
2971
2972    // --- Integration test cases added for faster iteration ---
2973
2974    #[test]
2975    fn test_pg_json_operators_7() {
2976        // JSONB ? operator followed by string literal — both should be kept as ?
2977        let got = super::obfuscate_sql(
2978            "select * from users where user.custom ? 'foo'",
2979            &SqlObfuscateConfig::default(),
2980            DbmsKind::Postgresql,
2981        );
2982        let expected = "select * from users where user.custom ? ?";
2983        assert_eq!(got, expected, "pg_json_7: {got:?}");
2984    }
2985
2986    #[test]
2987    fn test_quantizer_90() {
2988        // Inline comment /*!obfuscation*/ should be stripped; consecutive literals after = reset
2989        let config = SqlObfuscateConfig::default();
2990        let got = super::obfuscate_sql(
2991            "SELECT * FROM dbo.Items WHERE id = 1 or /*!obfuscation*/ 1 = 1",
2992            &config,
2993            DbmsKind::Generic,
2994        );
2995        let expected = "SELECT * FROM dbo.Items WHERE id = ? or ? = ?";
2996        assert_eq!(got, expected, "q90: {got:?}");
2997    }
2998
2999    #[test]
3000    fn test_cassandra_nested_dates() {
3001        // Consecutive ? placeholders inside nested function calls should be suppressed
3002        let config = SqlObfuscateConfig::default();
3003        let got = super::obfuscate_sql(
3004            "SELECT TO_DATE(TO_CHAR(TO_DATE(bar.h,?),?),?) FROM t",
3005            &config,
3006            DbmsKind::Generic,
3007        );
3008        let expected = "SELECT TO_DATE ( TO_CHAR ( TO_DATE ( bar.h, ? ) ) ) FROM t";
3009        assert_eq!(got, expected, "cassandra_nested_dates: {got:?}");
3010    }
3011
3012    #[test]
3013    fn test_cassandra_pipe_concat() {
3014        // || concatenation — Go tokenizes as two separate | tokens with spaces
3015        let config = SqlObfuscateConfig::default();
3016        let got = super::obfuscate_sql("SELECT a ||?|| b FROM t", &config, DbmsKind::Generic);
3017        let expected = "SELECT a | | ? | | b FROM t";
3018        assert_eq!(got, expected, "cassandra_pipe: {got:?}");
3019    }
3020
3021    // Test cases from the agent repo
3022    const SUITE_CASES: &[(&str, &str)] = &[
3023        // sql_keep_alias_off
3024        ("SELECT username AS person FROM users WHERE id=4", "SELECT username FROM users WHERE id = ?"),
3025        // sql_autovacuum_0
3026        ("autovacuum: VACUUM ANALYZE fake.table", "autovacuum : VACUUM ANALYZE fake.table"),
3027        // sql_autovacuum_1
3028        ("autovacuum: VACUUM ANALYZE fake.table_downtime", "autovacuum : VACUUM ANALYZE fake.table_downtime"),
3029        // sql_autovacuum_2
3030        ("autovacuum: VACUUM fake.big_table (to prevent wraparound)", "autovacuum : VACUUM fake.big_table ( to prevent wraparound )"),
3031        // sql_dollar_quoted_func_off
3032        ("SELECT $func$INSERT INTO table VALUES ('a', 1, 2)$func$ FROM users", "SELECT ? FROM users"),
3033        // sql_metadata_multiline_comment_select_insert
3034        ("\n/* Multi-line comment */\nSELECT * FROM clients WHERE (clients.first_name = 'Andy') LIMIT 1 BEGIN INSERT INTO owners (created_at, first_name, locked, orders_count, updated_at) VALUES ('2011-08-30 05:22:57', 'Andy', 1, NULL, '2011-08-30 05:22:57') COMMIT", "SELECT * FROM clients WHERE ( clients.first_name = ? ) LIMIT ? BEGIN INSERT INTO owners ( created_at, first_name, locked, orders_count, updated_at ) VALUES ( ? ) COMMIT"),
3035        // sql_metadata_multiline_comment_select_insert_lowercase_limit
3036        ("\n/* Multi-line comment */\nSELECT * FROM clients WHERE (clients.first_name = 'Andy') limit 1 BEGIN INSERT INTO owners (created_at, first_name, locked, orders_count, updated_at) VALUES ('2011-08-30 05:22:57', 'Andy', 1, NULL, '2011-08-30 05:22:57') COMMIT", "SELECT * FROM clients WHERE ( clients.first_name = ? ) limit ? BEGIN INSERT INTO owners ( created_at, first_name, locked, orders_count, updated_at ) VALUES ( ? ) COMMIT"),
3037        // sql_metadata_single_line_comments_grant
3038        ("\n-- Single line comment\n-- Another single line comment\n-- Another another single line comment\nGRANT USAGE, DELETE ON SCHEMA datadog TO datadog", "GRANT USAGE, DELETE ON SCHEMA datadog TO datadog"),
3039        // sql_metadata_no_collect
3040        ("\n/*\nMulti-line comment\nwith line breaks\n*/\n/* Two multi-line comments with\nline breaks */\nSELECT clients.* FROM clients INNER JOIN posts ON posts.author_id = author.id AND posts.published = 't'", "SELECT clients.* FROM clients INNER JOIN posts ON posts.author_id = author.id AND posts.published = ?"),
3041        // sql_metadata_create_trigger
3042        ("CREATE TRIGGER dogwatcher SELECT ON w1 BEFORE (UPDATE d1 SET (c1, c2, c3) = (c1 + 1, c2 + 1, c3 + 1))", "CREATE TRIGGER dogwatcher SELECT ON w1 BEFORE ( UPDATE d1 SET ( c1, c2, c3 ) = ( c1 + ? c2 + ? c3 + ? ) )"),
3043        // sql_metadata_table_value_constructor
3044        ("\n-- Testing table value constructor SQL expression\nSELECT * FROM (VALUES (1, 'dog')) AS d (id, animal)", "SELECT * FROM ( VALUES ( ? ) ) ( id, animal )"),
3045        // sql_metadata_alter_table
3046        ("ALTER TABLE table DROP COLUMN column", "ALTER TABLE table DROP COLUMN column"),
3047        // sql_metadata_revoke
3048        ("REVOKE ALL ON SCHEMA datadog FROM datadog", "REVOKE ALL ON SCHEMA datadog FROM datadog"),
3049        // sql_metadata_truncate
3050        ("TRUNCATE TABLE datadog", "TRUNCATE TABLE datadog"),
3051        // sql_metadata_explicit_table
3052        ("\n-- Testing explicit table SQL expression\nWITH T1 AS (SELECT PNO , PNAME , COLOR , WEIGHT , CITY FROM P WHERE  CITY = 'London'),\nT2 AS (SELECT PNO, PNAME, COLOR, WEIGHT, CITY, 2 * WEIGHT AS NEW_WEIGHT, 'Oslo' AS NEW_CITY FROM T1),\nT3 AS ( SELECT PNO , PNAME, COLOR, NEW_WEIGHT AS WEIGHT, NEW_CITY AS CITY FROM T2),\nT4 AS ( TABLE P EXCEPT CORRESPONDING TABLE T1)\nTABLE T4 UNION CORRESPONDING TABLE T3", "WITH T1 SELECT PNO, PNAME, COLOR, WEIGHT, CITY FROM P WHERE CITY = ? ) T2 SELECT PNO, PNAME, COLOR, WEIGHT, CITY, ? * WEIGHT, ? FROM T1 ), T3 SELECT PNO, PNAME, COLOR, NEW_WEIGHT, NEW_CITY FROM T2 ), T4 TABLE P EXCEPT CORRESPONDING TABLE T1 ) TABLE T4 UNION CORRESPONDING TABLE T3"),
3053        // sql_utf8_catalan_1
3054        ("SELECT Codi , Nom_CA AS Nom, Descripció_CAT AS Descripció FROM ProtValAptitud WHERE Vigent=1 ORDER BY Ordre, Codi", "SELECT Codi, Nom_CA, Descripció_CAT FROM ProtValAptitud WHERE Vigent = ? ORDER BY Ordre, Codi"),
3055        // sql_utf8_catalan_2
3056        (" SELECT  dbo.Treballadors_ProtCIE_AntecedentsPatologics.IdTreballadorsProtCIE_AntecedentsPatologics,   dbo.ProtCIE.Codi As CodiProtCIE, Treballadors_ProtCIE_AntecedentsPatologics.Año,                              dbo.ProtCIE.Nom_ES, dbo.ProtCIE.Nom_CA  FROM         dbo.Treballadors_ProtCIE_AntecedentsPatologics  WITH (NOLOCK)  INNER JOIN                       dbo.ProtCIE  WITH (NOLOCK)  ON dbo.Treballadors_ProtCIE_AntecedentsPatologics.CodiProtCIE = dbo.ProtCIE.Codi  WHERE Treballadors_ProtCIE_AntecedentsPatologics.IdTreballador =  12345 ORDER BY   Treballadors_ProtCIE_AntecedentsPatologics.Año DESC, dbo.ProtCIE.Codi ", "SELECT dbo.Treballadors_ProtCIE_AntecedentsPatologics.IdTreballadorsProtCIE_AntecedentsPatologics, dbo.ProtCIE.Codi, Treballadors_ProtCIE_AntecedentsPatologics.Año, dbo.ProtCIE.Nom_ES, dbo.ProtCIE.Nom_CA FROM dbo.Treballadors_ProtCIE_AntecedentsPatologics WITH ( NOLOCK ) INNER JOIN dbo.ProtCIE WITH ( NOLOCK ) ON dbo.Treballadors_ProtCIE_AntecedentsPatologics.CodiProtCIE = dbo.ProtCIE.Codi WHERE Treballadors_ProtCIE_AntecedentsPatologics.IdTreballador = ? ORDER BY Treballadors_ProtCIE_AntecedentsPatologics.Año DESC, dbo.ProtCIE.Codi"),
3057        // sql_utf8_catalan_3
3058        ("select  top 100 percent  IdTrebEmpresa as [IdTrebEmpresa], CodCli as [Client], NOMEMP as [Nom Client], Baixa as [Baixa], CASE WHEN IdCentreTreball IS NULL THEN '-' ELSE  CONVERT(VARCHAR(8),IdCentreTreball) END as [Id Centre],  CASE WHEN NOMESTAB IS NULL THEN '-' ELSE NOMESTAB END  as [Nom Centre],  TIPUS as [Tipus Lloc], CASE WHEN IdLloc IS NULL THEN '-' ELSE  CONVERT(VARCHAR(8),IdLloc) END  as [Id Lloc],  CASE WHEN NomLlocComplert IS NULL THEN '-' ELSE NomLlocComplert END  as [Lloc Treball],  CASE WHEN DesLloc IS NULL THEN '-' ELSE DesLloc END  as [Descripció], IdLlocTreballUnic as [Id Únic]  From ( SELECT    '-' AS TIPUS,  dbo.Treb_Empresa.IdTrebEmpresa, dbo.Treb_Empresa.IdTreballador, dbo.Treb_Empresa.CodCli, dbo.Clients.NOMEMP,   dbo.Treb_Empresa.Baixa,                      dbo.Treb_Empresa.IdCentreTreball, dbo.Cli_Establiments.NOMESTAB, null AS IdLloc,                        null AS NomLlocComplert, dbo.Treb_Empresa.DataInici,                        dbo.Treb_Empresa.DataFi, CASE WHEN dbo.Treb_Empresa.DesLloc IS NULL THEN '' ELSE dbo.Treb_Empresa.DesLloc END DesLloc, dbo.Treb_Empresa.IdLlocTreballUnic FROM         dbo.Clients  WITH (NOLOCK) INNER JOIN                       dbo.Treb_Empresa  WITH (NOLOCK) ON dbo.Clients.CODCLI = dbo.Treb_Empresa.CodCli LEFT OUTER JOIN                       dbo.Cli_Establiments  WITH (NOLOCK) ON dbo.Cli_Establiments.Id_ESTAB_CLI = dbo.Treb_Empresa.IdCentreTreball AND                        dbo.Cli_Establiments.CODCLI = dbo.Treb_Empresa.CodCli WHERE     dbo.Treb_Empresa.IdTreballador = 64376 AND Treb_Empresa.IdTecEIRLLlocTreball IS NULL AND IdMedEIRLLlocTreball IS NULL AND IdLlocTreballTemporal IS NULL  UNION ALL SELECT    'AV. RIESGO' AS TIPUS,  dbo.Treb_Empresa.IdTrebEmpresa, dbo.Treb_Empresa.IdTreballador, dbo.Treb_Empresa.CodCli, dbo.Clients.NOMEMP, dbo.Treb_Empresa.Baixa,                       dbo.Treb_Empresa.IdCentreTreball, dbo.Cli_Establiments.NOMESTAB, dbo.Treb_Empresa.IdTecEIRLLlocTreball AS IdLloc,                        dbo.fn_NomLlocComposat(dbo.Treb_Empresa.IdTecEIRLLlocTreball) AS NomLlocComplert, dbo.Treb_Empresa.DataInici,                        dbo.Treb_Empresa.DataFi, CASE WHEN dbo.Treb_Empresa.DesLloc IS NULL THEN '' ELSE dbo.Treb_Empresa.DesLloc END DesLloc, dbo.Treb_Empresa.IdLlocTreballUnic FROM         dbo.Clients  WITH (NOLOCK) INNER JOIN                       dbo.Treb_Empresa  WITH (NOLOCK) ON dbo.Clients.CODCLI = dbo.Treb_Empresa.CodCli LEFT OUTER JOIN                       dbo.Cli_Establiments  WITH (NOLOCK) ON dbo.Cli_Establiments.Id_ESTAB_CLI = dbo.Treb_Empresa.IdCentreTreball AND                        dbo.Cli_Establiments.CODCLI = dbo.Treb_Empresa.CodCli WHERE     (dbo.Treb_Empresa.IdTreballador = 64376) AND (NOT (dbo.Treb_Empresa.IdTecEIRLLlocTreball IS NULL))  UNION ALL SELECT     'EXTERNA' AS TIPUS,  dbo.Treb_Empresa.IdTrebEmpresa, dbo.Treb_Empresa.IdTreballador, dbo.Treb_Empresa.CodCli, dbo.Clients.NOMEMP,  dbo.Treb_Empresa.Baixa,                      dbo.Treb_Empresa.IdCentreTreball, dbo.Cli_Establiments.NOMESTAB, dbo.Treb_Empresa.IdMedEIRLLlocTreball AS IdLloc,                        dbo.fn_NomMedEIRLLlocComposat(dbo.Treb_Empresa.IdMedEIRLLlocTreball) AS NomLlocComplert,  dbo.Treb_Empresa.DataInici,                        dbo.Treb_Empresa.DataFi, CASE WHEN dbo.Treb_Empresa.DesLloc IS NULL THEN '' ELSE dbo.Treb_Empresa.DesLloc END DesLloc, dbo.Treb_Empresa.IdLlocTreballUnic FROM         dbo.Clients  WITH (NOLOCK) INNER JOIN                       dbo.Treb_Empresa  WITH (NOLOCK) ON dbo.Clients.CODCLI = dbo.Treb_Empresa.CodCli LEFT OUTER JOIN                       dbo.Cli_Establiments  WITH (NOLOCK) ON dbo.Cli_Establiments.Id_ESTAB_CLI = dbo.Treb_Empresa.IdCentreTreball AND                        dbo.Cli_Establiments.CODCLI = dbo.Treb_Empresa.CodCli WHERE     (dbo.Treb_Empresa.IdTreballador = 64376) AND (Treb_Empresa.IdTecEIRLLlocTreball IS NULL) AND (NOT (dbo.Treb_Empresa.IdMedEIRLLlocTreball IS NULL))  UNION ALL SELECT     'TEMPORAL' AS TIPUS,  dbo.Treb_Empresa.IdTrebEmpresa, dbo.Treb_Empresa.IdTreballador, dbo.Treb_Empresa.CodCli, dbo.Clients.NOMEMP, dbo.Treb_Empresa.Baixa,                       dbo.Treb_Empresa.IdCentreTreball, dbo.Cli_Establiments.NOMESTAB, dbo.Treb_Empresa.IdLlocTreballTemporal AS IdLloc,                       dbo.Lloc_Treball_Temporal.NomLlocTreball AS NomLlocComplert,  dbo.Treb_Empresa.DataInici,                        dbo.Treb_Empresa.DataFi, CASE WHEN dbo.Treb_Empresa.DesLloc IS NULL THEN '' ELSE dbo.Treb_Empresa.DesLloc END DesLloc, dbo.Treb_Empresa.IdLlocTreballUnic FROM         dbo.Clients  WITH (NOLOCK) INNER JOIN                       dbo.Treb_Empresa  WITH (NOLOCK) ON dbo.Clients.CODCLI = dbo.Treb_Empresa.CodCli INNER JOIN                       dbo.Lloc_Treball_Temporal  WITH (NOLOCK) ON dbo.Treb_Empresa.IdLlocTreballTemporal = dbo.Lloc_Treball_Temporal.IdLlocTreballTemporal LEFT OUTER JOIN                       dbo.Cli_Establiments  WITH (NOLOCK) ON dbo.Cli_Establiments.Id_ESTAB_CLI = dbo.Treb_Empresa.IdCentreTreball AND                        dbo.Cli_Establiments.CODCLI = dbo.Treb_Empresa.CodCli WHERE     dbo.Treb_Empresa.IdTreballador = 64376 AND Treb_Empresa.IdTecEIRLLlocTreball IS NULL AND IdMedEIRLLlocTreball IS NULL ) as taula  Where 1=0 ", "select top ? percent IdTrebEmpresa, CodCli, NOMEMP, Baixa, CASE WHEN IdCentreTreball IS ? THEN ? ELSE CONVERT ( VARCHAR ( ? ) IdCentreTreball ) END, CASE WHEN NOMESTAB IS ? THEN ? ELSE NOMESTAB END, TIPUS, CASE WHEN IdLloc IS ? THEN ? ELSE CONVERT ( VARCHAR ( ? ) IdLloc ) END, CASE WHEN NomLlocComplert IS ? THEN ? ELSE NomLlocComplert END, CASE WHEN DesLloc IS ? THEN ? ELSE DesLloc END, IdLlocTreballUnic From ( SELECT ?, dbo.Treb_Empresa.IdTrebEmpresa, dbo.Treb_Empresa.IdTreballador, dbo.Treb_Empresa.CodCli, dbo.Clients.NOMEMP, dbo.Treb_Empresa.Baixa, dbo.Treb_Empresa.IdCentreTreball, dbo.Cli_Establiments.NOMESTAB, ?, ?, dbo.Treb_Empresa.DataInici, dbo.Treb_Empresa.DataFi, CASE WHEN dbo.Treb_Empresa.DesLloc IS ? THEN ? ELSE dbo.Treb_Empresa.DesLloc END DesLloc, dbo.Treb_Empresa.IdLlocTreballUnic FROM dbo.Clients WITH ( NOLOCK ) INNER JOIN dbo.Treb_Empresa WITH ( NOLOCK ) ON dbo.Clients.CODCLI = dbo.Treb_Empresa.CodCli LEFT OUTER JOIN dbo.Cli_Establiments WITH ( NOLOCK ) ON dbo.Cli_Establiments.Id_ESTAB_CLI = dbo.Treb_Empresa.IdCentreTreball AND dbo.Cli_Establiments.CODCLI = dbo.Treb_Empresa.CodCli WHERE dbo.Treb_Empresa.IdTreballador = ? AND Treb_Empresa.IdTecEIRLLlocTreball IS ? AND IdMedEIRLLlocTreball IS ? AND IdLlocTreballTemporal IS ? UNION ALL SELECT ?, dbo.Treb_Empresa.IdTrebEmpresa, dbo.Treb_Empresa.IdTreballador, dbo.Treb_Empresa.CodCli, dbo.Clients.NOMEMP, dbo.Treb_Empresa.Baixa, dbo.Treb_Empresa.IdCentreTreball, dbo.Cli_Establiments.NOMESTAB, dbo.Treb_Empresa.IdTecEIRLLlocTreball, dbo.fn_NomLlocComposat ( dbo.Treb_Empresa.IdTecEIRLLlocTreball ), dbo.Treb_Empresa.DataInici, dbo.Treb_Empresa.DataFi, CASE WHEN dbo.Treb_Empresa.DesLloc IS ? THEN ? ELSE dbo.Treb_Empresa.DesLloc END DesLloc, dbo.Treb_Empresa.IdLlocTreballUnic FROM dbo.Clients WITH ( NOLOCK ) INNER JOIN dbo.Treb_Empresa WITH ( NOLOCK ) ON dbo.Clients.CODCLI = dbo.Treb_Empresa.CodCli LEFT OUTER JOIN dbo.Cli_Establiments WITH ( NOLOCK ) ON dbo.Cli_Establiments.Id_ESTAB_CLI = dbo.Treb_Empresa.IdCentreTreball AND dbo.Cli_Establiments.CODCLI = dbo.Treb_Empresa.CodCli WHERE ( dbo.Treb_Empresa.IdTreballador = ? ) AND ( NOT ( dbo.Treb_Empresa.IdTecEIRLLlocTreball IS ? ) ) UNION ALL SELECT ?, dbo.Treb_Empresa.IdTrebEmpresa, dbo.Treb_Empresa.IdTreballador, dbo.Treb_Empresa.CodCli, dbo.Clients.NOMEMP, dbo.Treb_Empresa.Baixa, dbo.Treb_Empresa.IdCentreTreball, dbo.Cli_Establiments.NOMESTAB, dbo.Treb_Empresa.IdMedEIRLLlocTreball, dbo.fn_NomMedEIRLLlocComposat ( dbo.Treb_Empresa.IdMedEIRLLlocTreball ), dbo.Treb_Empresa.DataInici, dbo.Treb_Empresa.DataFi, CASE WHEN dbo.Treb_Empresa.DesLloc IS ? THEN ? ELSE dbo.Treb_Empresa.DesLloc END DesLloc, dbo.Treb_Empresa.IdLlocTreballUnic FROM dbo.Clients WITH ( NOLOCK ) INNER JOIN dbo.Treb_Empresa WITH ( NOLOCK ) ON dbo.Clients.CODCLI = dbo.Treb_Empresa.CodCli LEFT OUTER JOIN dbo.Cli_Establiments WITH ( NOLOCK ) ON dbo.Cli_Establiments.Id_ESTAB_CLI = dbo.Treb_Empresa.IdCentreTreball AND dbo.Cli_Establiments.CODCLI = dbo.Treb_Empresa.CodCli WHERE ( dbo.Treb_Empresa.IdTreballador = ? ) AND ( Treb_Empresa.IdTecEIRLLlocTreball IS ? ) AND ( NOT ( dbo.Treb_Empresa.IdMedEIRLLlocTreball IS ? ) ) UNION ALL SELECT ?, dbo.Treb_Empresa.IdTrebEmpresa, dbo.Treb_Empresa.IdTreballador, dbo.Treb_Empresa.CodCli, dbo.Clients.NOMEMP, dbo.Treb_Empresa.Baixa, dbo.Treb_Empresa.IdCentreTreball, dbo.Cli_Establiments.NOMESTAB, dbo.Treb_Empresa.IdLlocTreballTemporal, dbo.Lloc_Treball_Temporal.NomLlocTreball, dbo.Treb_Empresa.DataInici, dbo.Treb_Empresa.DataFi, CASE WHEN dbo.Treb_Empresa.DesLloc IS ? THEN ? ELSE dbo.Treb_Empresa.DesLloc END DesLloc, dbo.Treb_Empresa.IdLlocTreballUnic FROM dbo.Clients WITH ( NOLOCK ) INNER JOIN dbo.Treb_Empresa WITH ( NOLOCK ) ON dbo.Clients.CODCLI = dbo.Treb_Empresa.CodCli INNER JOIN dbo.Lloc_Treball_Temporal WITH ( NOLOCK ) ON dbo.Treb_Empresa.IdLlocTreballTemporal = dbo.Lloc_Treball_Temporal.IdLlocTreballTemporal LEFT OUTER JOIN dbo.Cli_Establiments WITH ( NOLOCK ) ON dbo.Cli_Establiments.Id_ESTAB_CLI = dbo.Treb_Empresa.IdCentreTreball AND dbo.Cli_Establiments.CODCLI = dbo.Treb_Empresa.CodCli WHERE dbo.Treb_Empresa.IdTreballador = ? AND Treb_Empresa.IdTecEIRLLlocTreball IS ? AND IdMedEIRLLlocTreball IS ? ) Where ? = ?"),
3059        // sql_utf8_catalan_4
3060        ("select  IdHistLabAnt as [IdHistLabAnt], IdTreballador as [IdTreballador], Empresa as [Professió], Anys as [Anys],  Riscs as [Riscos], Nom_CA AS [Prot CNO], Nom_ES as [Prot CNO Altre Idioma]   From ( SELECT     dbo.Treb_HistAnt.IdHistLabAnt, dbo.Treb_HistAnt.IdTreballador,           dbo.Treb_HistAnt.Empresa, dbo.Treb_HistAnt.Anys, dbo.Treb_HistAnt.Riscs, dbo.Treb_HistAnt.CodiProtCNO,           dbo.ProtCNO.Nom_ES, dbo.ProtCNO.Nom_CA  FROM     dbo.Treb_HistAnt  WITH (NOLOCK) LEFT OUTER JOIN                       dbo.ProtCNO  WITH (NOLOCK) ON dbo.Treb_HistAnt.CodiProtCNO = dbo.ProtCNO.Codi  Where  dbo.Treb_HistAnt.IdTreballador = 12345 ) as taula ", "select IdHistLabAnt, IdTreballador, Empresa, Anys, Riscs, Nom_CA, Nom_ES From ( SELECT dbo.Treb_HistAnt.IdHistLabAnt, dbo.Treb_HistAnt.IdTreballador, dbo.Treb_HistAnt.Empresa, dbo.Treb_HistAnt.Anys, dbo.Treb_HistAnt.Riscs, dbo.Treb_HistAnt.CodiProtCNO, dbo.ProtCNO.Nom_ES, dbo.ProtCNO.Nom_CA FROM dbo.Treb_HistAnt WITH ( NOLOCK ) LEFT OUTER JOIN dbo.ProtCNO WITH ( NOLOCK ) ON dbo.Treb_HistAnt.CodiProtCNO = dbo.ProtCNO.Codi Where dbo.Treb_HistAnt.IdTreballador = ? )"),
3061        // sql_utf8_catalan_5
3062        ("SELECT     Cli_Establiments.CODCLI, Cli_Establiments.Id_ESTAB_CLI As [Código Centro Trabajo], Cli_Establiments.CODIGO_CENTRO_AXAPTA As [Código C. Axapta],  Cli_Establiments.NOMESTAB As [Nombre],                                 Cli_Establiments.ADRECA As [Dirección], Cli_Establiments.CodPostal As [Código Postal], Cli_Establiments.Poblacio as [Población], Cli_Establiments.Provincia,                                Cli_Establiments.TEL As [Tel],  Cli_Establiments.EMAIL As [EMAIL],                                Cli_Establiments.PERS_CONTACTE As [Contacto], Cli_Establiments.PERS_CONTACTE_CARREC As [Cargo Contacto], Cli_Establiments.NumTreb As [Plantilla],                                Cli_Establiments.Localitzacio As [Localización], Tipus_Activitat.CNAE, Tipus_Activitat.Nom_ES As [Nombre Actividad], ACTIVO AS [Activo]                        FROM         Cli_Establiments LEFT OUTER JOIN                                    Tipus_Activitat ON Cli_Establiments.Id_ACTIVITAT = Tipus_Activitat.IdActivitat                        Where CODCLI = '01234' AND CENTRE_CORRECTE = 3 AND ACTIVO = 5                        ORDER BY Cli_Establiments.CODIGO_CENTRO_AXAPTA ", "SELECT Cli_Establiments.CODCLI, Cli_Establiments.Id_ESTAB_CLI, Cli_Establiments.CODIGO_CENTRO_AXAPTA, Cli_Establiments.NOMESTAB, Cli_Establiments.ADRECA, Cli_Establiments.CodPostal, Cli_Establiments.Poblacio, Cli_Establiments.Provincia, Cli_Establiments.TEL, Cli_Establiments.EMAIL, Cli_Establiments.PERS_CONTACTE, Cli_Establiments.PERS_CONTACTE_CARREC, Cli_Establiments.NumTreb, Cli_Establiments.Localitzacio, Tipus_Activitat.CNAE, Tipus_Activitat.Nom_ES, ACTIVO FROM Cli_Establiments LEFT OUTER JOIN Tipus_Activitat ON Cli_Establiments.Id_ACTIVITAT = Tipus_Activitat.IdActivitat Where CODCLI = ? AND CENTRE_CORRECTE = ? AND ACTIVO = ? ORDER BY Cli_Establiments.CODIGO_CENTRO_AXAPTA"),
3063        // sql_utf8_dollar_field
3064        ("select * from dollarField$ as df from some$dollar$filled_thing$$;", "select * from dollarField$ from some$dollar$filled_thing$$"),
3065        // sql_utf8_backtick_jp
3066        ("select * from `構わない`;", "select * from 構わない"),
3067        // sql_utf8_replacement_chars_1
3068        ("select * from names where name like '�����';", "select * from names where name like ?"),
3069        // sql_utf8_replacement_chars_2
3070        ("select replacement from table where replacement = 'i�n�t�e��rspersed';", "select replacement from table where replacement = ?"),
3071        // sql_utf8_replacement_char_3
3072        ("SELECT ('�');", "SELECT ( ? )"),
3073        // sql_replace_digits_off_0
3074        ("REPLACE INTO sales_2019_07_01 (`itemID`, `date`, `qty`, `price`) VALUES ((SELECT itemID FROM item1001 WHERE `sku` = [sku]), CURDATE(), [qty], 0.00)", "REPLACE INTO sales_2019_07_01 ( itemID, date, qty, price ) VALUES ( ( SELECT itemID FROM item1001 WHERE sku = [ sku ] ), CURDATE ( ), [ qty ], ? )"),
3075        // sql_replace_digits_off_1
3076        ("SELECT ddh19.name, ddt.tags FROM dd91219.host ddh19, dd21916.host_tags ddt WHERE ddh19.id = ddt.host_id AND ddh19.org_id = 2 AND ddh19.name = 'datadog'", "SELECT ddh19.name, ddt.tags FROM dd91219.host ddh19, dd21916.host_tags ddt WHERE ddh19.id = ddt.host_id AND ddh19.org_id = ? AND ddh19.name = ?"),
3077        // sql_replace_digits_off_2
3078        ("SELECT ddu2.name, ddo.id10, ddk.app_key52 FROM dd3120.user ddu2, dd1931.orgs55 ddo, dd53819.keys ddk", "SELECT ddu2.name, ddo.id10, ddk.app_key52 FROM dd3120.user ddu2, dd1931.orgs55 ddo, dd53819.keys ddk"),
3079        // sql_replace_digits_off_3
3080        ("SELECT daily_values1529.*, LEAST((5040000 - @runtot), value1830) AS value1830,\n(@runtot := @runtot + daily_values1529.value1830) AS total\nFROM (SELECT @runtot:=0) AS n,\ndaily_values1529 WHERE daily_values1529.subject_id = 12345 AND daily_values1592.subject_type = 'Skippity'\nAND (daily_values1529.date BETWEEN '2018-05-09' AND '2018-06-19') HAVING value >= 0 ORDER BY date", "SELECT daily_values1529.*, LEAST ( ( ? - @runtot ), value1830 ), ( @runtot := @runtot + daily_values1529.value1830 ) FROM ( SELECT @runtot := ? ), daily_values1529 WHERE daily_values1529.subject_id = ? AND daily_values1592.subject_type = ? AND ( daily_values1529.date BETWEEN ? AND ? ) HAVING value >= ? ORDER BY date"),
3081        // sql_replace_digits_off_4
3082        ("WITH sales AS\n(SELECT sf2.*\n\tFROM gosalesdw28391.sls_order_method_dim AS md,\n\t\tgosalesdw1920.sls_product_dim391 AS pd190,\n\t\tgosalesdw3819.emp_employee_dim AS ed,\n\t\tgosalesdw3919.sls_sales_fact3819 AS sf2\n\tWHERE pd190.product_key = sf2.product_key\n\tAND pd190.product_number381 > 10000\n\tAND pd190.base_product_key > 30\n\tAND md.order_method_key = sf2.order_method_key8319\n\tAND md.order_method_code > 5\n\tAND ed.employee_key = sf2.employee_key\n\tAND ed.manager_code1 > 20),\ninventory3118 AS\n(SELECT if.*\n\tFROM gosalesdw1592.go_branch_dim AS bd3221,\n\tgosalesdw.dist_inventory_fact AS if\n\tWHERE if.branch_key = bd3221.branch_key\n\tAND bd3221.branch_code > 20)\nSELECT sales1828.product_key AS PROD_KEY,\nSUM(CAST (inventory3118.quantity_shipped AS BIGINT)) AS INV_SHIPPED3118,\nSUM(CAST (sales1828.quantity AS BIGINT)) AS PROD_QUANTITY,\nRANK() OVER ( ORDER BY SUM(CAST (sales1828.quantity AS BIGINT)) DESC) AS PROD_RANK\nFROM sales1828, inventory3118\nWHERE sales1828.product_key = inventory3118.product_key\nGROUP BY sales1828.product_key", "WITH sales SELECT sf2.* FROM gosalesdw28391.sls_order_method_dim, gosalesdw1920.sls_product_dim391, gosalesdw3819.emp_employee_dim, gosalesdw3919.sls_sales_fact3819 WHERE pd190.product_key = sf2.product_key AND pd190.product_number381 > ? AND pd190.base_product_key > ? AND md.order_method_key = sf2.order_method_key8319 AND md.order_method_code > ? AND ed.employee_key = sf2.employee_key AND ed.manager_code1 > ? ) inventory3118 SELECT if.* FROM gosalesdw1592.go_branch_dim, gosalesdw.dist_inventory_fact WHERE if.branch_key = bd3221.branch_key AND bd3221.branch_code > ? ) SELECT sales1828.product_key, SUM ( CAST ( inventory3118.quantity_shipped ) ), SUM ( CAST ( sales1828.quantity ) ), RANK ( ) OVER ( ORDER BY SUM ( CAST ( sales1828.quantity ) ) DESC ) FROM sales1828, inventory3118 WHERE sales1828.product_key = inventory3118.product_key GROUP BY sales1828.product_key"),
3083        // sql_quantizer_0
3084        ("SELECT \"table\".\"field\" FROM \"table\" WHERE \"table\".\"otherfield\" = $? AND \"table\".\"thirdfield\" = $?;", "SELECT table . field FROM table WHERE table . otherfield = ? AND table . thirdfield = ?"),
3085        // sql_quantizer_1
3086        ("select * from users where id = 42", "select * from users where id = ?"),
3087        // sql_quantizer_2
3088        ("select * from users where float = .43422", "select * from users where float = ?"),
3089        // sql_quantizer_3
3090        ("SELECT host, status FROM ec2_status WHERE org_id = 42", "SELECT host, status FROM ec2_status WHERE org_id = ?"),
3091        // sql_quantizer_4
3092        ("SELECT host, status FROM ec2_status WHERE org_id=42", "SELECT host, status FROM ec2_status WHERE org_id = ?"),
3093        // sql_quantizer_5
3094        ("-- get user \n--\n select * \n   from users \n    where\n       id = 214325346", "select * from users where id = ?"),
3095        // sql_quantizer_6
3096        ("SELECT * FROM `host` WHERE `id` IN (42, 43) /*comment with parameters,host:localhost,url:controller#home,id:FF005:00CAA*/", "SELECT * FROM host WHERE id IN ( ? )"),
3097        // sql_quantizer_7
3098        ("SELECT `host`.`address` FROM `host` WHERE org_id=42", "SELECT host . address FROM host WHERE org_id = ?"),
3099        // sql_quantizer_8
3100        ("SELECT \"host\".\"address\" FROM \"host\" WHERE org_id=42", "SELECT host . address FROM host WHERE org_id = ?"),
3101        // sql_quantizer_9
3102        ("SELECT * FROM host WHERE id IN (42, 43) /*\n\t\t\tmultiline comment with parameters,\n\t\t\thost:localhost,url:controller#home,id:FF005:00CAA\n\t\t\t*/", "SELECT * FROM host WHERE id IN ( ? )"),
3103        // sql_quantizer_10
3104        ("UPDATE user_dash_pref SET json_prefs = %(json_prefs)s, modified = '2015-08-27 22:10:32.492912' WHERE user_id = %(user_id)s AND url = %(url)s", "UPDATE user_dash_pref SET json_prefs = ? modified = ? WHERE user_id = ? AND url = ?"),
3105        // sql_quantizer_11
3106        ("SELECT DISTINCT host.id AS host_id FROM host JOIN host_alias ON host_alias.host_id = host.id WHERE host.org_id = %(org_id_1)s AND host.name NOT IN (%(name_1)s) AND host.name IN (%(name_2)s, %(name_3)s, %(name_4)s, %(name_5)s)", "SELECT DISTINCT host.id FROM host JOIN host_alias ON host_alias.host_id = host.id WHERE host.org_id = ? AND host.name NOT IN ( ? ) AND host.name IN ( ? )"),
3107        // sql_quantizer_12
3108        ("SELECT org_id, metric_key FROM metrics_metadata WHERE org_id = %(org_id)s AND metric_key = ANY(array[75])", "SELECT org_id, metric_key FROM metrics_metadata WHERE org_id = ? AND metric_key = ANY ( array [ ? ] )"),
3109        // sql_quantizer_13
3110        ("SELECT org_id, metric_key   FROM metrics_metadata   WHERE org_id = %(org_id)s AND metric_key = ANY(array[21, 25, 32])", "SELECT org_id, metric_key FROM metrics_metadata WHERE org_id = ? AND metric_key = ANY ( array [ ? ] )"),
3111        // sql_quantizer_14
3112        ("SELECT articles.* FROM articles WHERE articles.id = 1 LIMIT 1", "SELECT articles.* FROM articles WHERE articles.id = ? LIMIT ?"),
3113        // sql_quantizer_lowercase_limit
3114        ("SELECT articles.* FROM articles WHERE articles.id = 1 limit 1", "SELECT articles.* FROM articles WHERE articles.id = ? limit ?"),
3115        // sql_quantizer_15
3116        ("SELECT articles.* FROM articles WHERE articles.id = 1 LIMIT 1, 20", "SELECT articles.* FROM articles WHERE articles.id = ? LIMIT ?"),
3117        // sql_quantizer_16
3118        ("SELECT articles.* FROM articles WHERE articles.id = 1 LIMIT 1, 20;", "SELECT articles.* FROM articles WHERE articles.id = ? LIMIT ?"),
3119        // sql_quantizer_limit_two_arguments_lowercase
3120        ("SELECT articles.* FROM articles WHERE articles.id = 1 LIMIT 1, 20;", "SELECT articles.* FROM articles WHERE articles.id = ? LIMIT ?"),
3121        // sql_quantizer_17
3122        ("SELECT articles.* FROM articles WHERE articles.id = 1 LIMIT 15,20;", "SELECT articles.* FROM articles WHERE articles.id = ? LIMIT ?"),
3123        // sql_quantizer_18
3124        ("SELECT articles.* FROM articles WHERE articles.id = 1 LIMIT 1;", "SELECT articles.* FROM articles WHERE articles.id = ? LIMIT ?"),
3125        // sql_quantizer_19
3126        ("SELECT articles.* FROM articles WHERE (articles.created_at BETWEEN '2016-10-31 23:00:00.000000' AND '2016-11-01 23:00:00.000000')", "SELECT articles.* FROM articles WHERE ( articles.created_at BETWEEN ? AND ? )"),
3127        // sql_quantizer_20
3128        ("SELECT articles.* FROM articles WHERE (articles.created_at BETWEEN $1 AND $2)", "SELECT articles.* FROM articles WHERE ( articles.created_at BETWEEN ? AND ? )"),
3129        // sql_quantizer_21
3130        ("SELECT articles.* FROM articles WHERE (articles.published != true)", "SELECT articles.* FROM articles WHERE ( articles.published != ? )"),
3131        // sql_quantizer_22
3132        ("SELECT articles.* FROM articles WHERE (title = 'guides.rubyonrails.org')", "SELECT articles.* FROM articles WHERE ( title = ? )"),
3133        // sql_quantizer_23
3134        ("SELECT articles.* FROM articles WHERE ( title = ? ) AND ( author = ? )", "SELECT articles.* FROM articles WHERE ( title = ? ) AND ( author = ? )"),
3135        // sql_quantizer_24
3136        ("SELECT articles.* FROM articles WHERE ( title = :title )", "SELECT articles.* FROM articles WHERE ( title = :title )"),
3137        // sql_quantizer_25
3138        ("SELECT articles.* FROM articles WHERE ( title = @title )", "SELECT articles.* FROM articles WHERE ( title = @title )"),
3139        // sql_quantizer_26
3140        ("SELECT date(created_at) as ordered_date, sum(price) as total_price FROM orders GROUP BY date(created_at) HAVING sum(price) > 100", "SELECT date ( created_at ), sum ( price ) FROM orders GROUP BY date ( created_at ) HAVING sum ( price ) > ?"),
3141        // sql_quantizer_27
3142        ("SELECT * FROM articles WHERE id > 10 ORDER BY id asc LIMIT 20", "SELECT * FROM articles WHERE id > ? ORDER BY id asc LIMIT ?"),
3143        // sql_quantizer_28
3144        ("SELECT clients.* FROM clients INNER JOIN posts ON posts.author_id = author.id AND posts.published = 't'", "SELECT clients.* FROM clients INNER JOIN posts ON posts.author_id = author.id AND posts.published = ?"),
3145        // sql_quantizer_29
3146        ("SELECT articles.* FROM articles WHERE articles.id IN (1, 3, 5)", "SELECT articles.* FROM articles WHERE articles.id IN ( ? )"),
3147        // sql_quantizer_30
3148        ("SELECT * FROM clients WHERE (clients.first_name = 'Andy') LIMIT 1 BEGIN INSERT INTO clients (created_at, first_name, locked, orders_count, updated_at) VALUES ('2011-08-30 05:22:57', 'Andy', 1, NULL, '2011-08-30 05:22:57') COMMIT", "SELECT * FROM clients WHERE ( clients.first_name = ? ) LIMIT ? BEGIN INSERT INTO clients ( created_at, first_name, locked, orders_count, updated_at ) VALUES ( ? ) COMMIT"),
3149        // sql_quantizer_31
3150        ("SELECT * FROM clients WHERE (clients.first_name = 'Andy') LIMIT 15, 25 BEGIN INSERT INTO clients (created_at, first_name, locked, orders_count, updated_at) VALUES ('2011-08-30 05:22:57', 'Andy', 1, NULL, '2011-08-30 05:22:57') COMMIT", "SELECT * FROM clients WHERE ( clients.first_name = ? ) LIMIT ? BEGIN INSERT INTO clients ( created_at, first_name, locked, orders_count, updated_at ) VALUES ( ? ) COMMIT"),
3151        // sql_quantizer_32
3152        ("SAVEPOINT \"s139956586256192_x1\"", "SAVEPOINT ?"),
3153        // sql_quantizer_33
3154        ("INSERT INTO user (id, username) VALUES ('Fred','Smith'), ('John','Smith'), ('Michael','Smith'), ('Robert','Smith');", "INSERT INTO user ( id, username ) VALUES ( ? )"),
3155        // sql_quantizer_34
3156        ("CREATE KEYSPACE Excelsior WITH replication = {'class': 'SimpleStrategy', 'replication_factor' : 3};", "CREATE KEYSPACE Excelsior WITH replication = ?"),
3157        // sql_quantizer_35
3158        ("SELECT \"webcore_page\".\"id\" FROM \"webcore_page\" WHERE \"webcore_page\".\"slug\" = %s ORDER BY \"webcore_page\".\"path\" ASC LIMIT 1", "SELECT webcore_page . id FROM webcore_page WHERE webcore_page . slug = ? ORDER BY webcore_page . path ASC LIMIT ?"),
3159        // sql_quantizer_36
3160        ("SELECT server_table.host AS host_id FROM table#.host_tags as server_table WHERE server_table.host_id = 50", "SELECT server_table.host FROM table#.host_tags WHERE server_table.host_id = ?"),
3161        // sql_quantizer_37
3162        ("INSERT INTO delayed_jobs (attempts, created_at, failed_at, handler, last_error, locked_at, locked_by, priority, queue, run_at, updated_at) VALUES (0, '2016-12-04 17:09:59', NULL, '--- !ruby/object:Delayed::PerformableMethod\nobject: !ruby/object:Item\n  store:\n  - a simple string\n  - an \\'escaped \\' string\n  - another \\'escaped\\' string\n  - 42\n  string: a string with many \\\\\\\\\\'escapes\\\\\\\\\\'\nmethod_name: :show_store\nargs: []\n', NULL, NULL, NULL, 0, NULL, '2016-12-04 17:09:59', '2016-12-04 17:09:59')", "INSERT INTO delayed_jobs ( attempts, created_at, failed_at, handler, last_error, locked_at, locked_by, priority, queue, run_at, updated_at ) VALUES ( ? )"),
3163        // sql_quantizer_38
3164        ("SELECT name, pretty_print(address) FROM people;", "SELECT name, pretty_print ( address ) FROM people"),
3165        // sql_quantizer_39
3166        ("* SELECT * FROM fake_data(1, 2, 3);", "* SELECT * FROM fake_data ( ? )"),
3167        // sql_quantizer_40
3168        ("CREATE FUNCTION add(integer, integer) RETURNS integer\n AS 'select $1 + $2;'\n LANGUAGE SQL\n IMMUTABLE\n RETURNS NULL ON NULL INPUT;", "CREATE FUNCTION add ( integer, integer ) RETURNS integer LANGUAGE SQL IMMUTABLE RETURNS ? ON ? INPUT"),
3169        // sql_quantizer_41
3170        ("SELECT * FROM public.table ( array [ ROW ( array [ 'magic', 'foo',", "SELECT * FROM public.table ( array [ ROW ( array [ ?"),
3171        // sql_quantizer_42
3172        ("SELECT pg_try_advisory_lock (123) AS t46eef3f025cc27feb31ca5a2d668a09a", "SELECT pg_try_advisory_lock ( ? )"),
3173        // sql_quantizer_43
3174        ("INSERT INTO `qual-aa`.issues (alert0 , alert1) VALUES (NULL, NULL)", "INSERT INTO qual-aa . issues ( alert0, alert1 ) VALUES ( ? )"),
3175        // sql_quantizer_44
3176        ("INSERT INTO user (id, email, name) VALUES (null, ?, ?)", "INSERT INTO user ( id, email, name ) VALUES ( ? )"),
3177        // sql_quantizer_45
3178        ("select * from users where id = 214325346     # This comment continues to the end of line", "select * from users where id = ?"),
3179        // sql_quantizer_46
3180        ("select * from users where id = 214325346     -- This comment continues to the end of line", "select * from users where id = ?"),
3181        // sql_quantizer_47
3182        ("SELECT * FROM /* this is an in-line comment */ users;", "SELECT * FROM users"),
3183        // sql_quantizer_48
3184        ("SELECT /*! STRAIGHT_JOIN */ col1 FROM table1", "SELECT col1 FROM table1"),
3185        // sql_quantizer_49
3186        ("DELETE FROM t1\n\t\t\tWHERE s11 > ANY\n\t\t\t(SELECT COUNT(*) /* no hint */ FROM t2\n\t\t\tWHERE NOT EXISTS\n\t\t\t(SELECT * FROM t3\n\t\t\tWHERE ROW(5*t2.s1,77)=\n\t\t\t(SELECT 50,11*s1 FROM t4 UNION SELECT 50,77 FROM\n\t\t\t(SELECT * FROM t5) AS t5)));", "DELETE FROM t1 WHERE s11 > ANY ( SELECT COUNT ( * ) FROM t2 WHERE NOT EXISTS ( SELECT * FROM t3 WHERE ROW ( ? * t2.s1, ? ) = ( SELECT ? * s1 FROM t4 UNION SELECT ? FROM ( SELECT * FROM t5 ) ) ) )"),
3187        // sql_quantizer_50
3188        ("SET @g = 'POLYGON((0 0,10 0,10 10,0 10,0 0),(5 5,7 5,7 7,5 7, 5 5))';", "SET @g = ?"),
3189        // sql_quantizer_51
3190        ("SELECT daily_values.*,\n                    LEAST((5040000 - @runtot), value) AS value,\n                    (@runtot := @runtot + daily_values.value) AS total FROM (SELECT @runtot:=0) AS n, `daily_values`  WHERE `daily_values`.`subject_id` = 12345 AND `daily_values`.`subject_type` = 'Skippity' AND (daily_values.date BETWEEN '2018-05-09' AND '2018-06-19') HAVING value >= 0 ORDER BY date", "SELECT daily_values.*, LEAST ( ( ? - @runtot ), value ), ( @runtot := @runtot + daily_values.value ) FROM ( SELECT @runtot := ? ), daily_values WHERE daily_values . subject_id = ? AND daily_values . subject_type = ? AND ( daily_values.date BETWEEN ? AND ? ) HAVING value >= ? ORDER BY date"),
3191        // sql_quantizer_52
3192        ("    SELECT\n      t1.userid,\n      t1.fullname,\n      t1.firm_id,\n      t2.firmname,\n      t1.email,\n      t1.location,\n      t1.state,\n      t1.phone,\n      t1.url,\n      DATE_FORMAT( t1.lastmod, \"%m/%d/%Y %h:%i:%s\" ) AS lastmod,\n      t1.lastmod AS lastmod_raw,\n      t1.user_status,\n      t1.pw_expire,\n      DATE_FORMAT( t1.pw_expire, \"%m/%d/%Y\" ) AS pw_expire_date,\n      t1.addr1,\n      t1.addr2,\n      t1.zipcode,\n      t1.office_id,\n      t1.default_group,\n      t3.firm_status,\n      t1.title\n    FROM\n           userdata      AS t1\n      LEFT JOIN lawfirm_names AS t2 ON t1.firm_id = t2.firm_id\n      LEFT JOIN lawfirms      AS t3 ON t1.firm_id = t3.firm_id\n    WHERE\n      t1.userid = 'jstein'\n\n  ", "SELECT t1.userid, t1.fullname, t1.firm_id, t2.firmname, t1.email, t1.location, t1.state, t1.phone, t1.url, DATE_FORMAT ( t1.lastmod, %m/%d/%Y %h:%i:%s ), t1.lastmod, t1.user_status, t1.pw_expire, DATE_FORMAT ( t1.pw_expire, %m/%d/%Y ), t1.addr1, t1.addr2, t1.zipcode, t1.office_id, t1.default_group, t3.firm_status, t1.title FROM userdata LEFT JOIN lawfirm_names ON t1.firm_id = t2.firm_id LEFT JOIN lawfirms ON t1.firm_id = t3.firm_id WHERE t1.userid = ?"),
3193        // sql_quantizer_53
3194        ("SELECT [b].[BlogId], [b].[Name]\nFROM [Blogs] AS [b]\nORDER BY [b].[Name]", "SELECT [ b ] . [ BlogId ], [ b ] . [ Name ] FROM [ Blogs ] ORDER BY [ b ] . [ Name ]"),
3195        // sql_quantizer_54
3196        ("SELECT * FROM users WHERE firstname=''", "SELECT * FROM users WHERE firstname = ?"),
3197        // sql_quantizer_55
3198        ("SELECT * FROM users WHERE firstname=' '", "SELECT * FROM users WHERE firstname = ?"),
3199        // sql_quantizer_56
3200        ("SELECT * FROM users WHERE firstname=\"\"", "SELECT * FROM users WHERE firstname = ?"),
3201        // sql_quantizer_57
3202        ("SELECT * FROM users WHERE lastname=\" \"", "SELECT * FROM users WHERE lastname = ?"),
3203        // sql_quantizer_58
3204        ("SELECT * FROM users WHERE lastname=\"\t \"", "SELECT * FROM users WHERE lastname = ?"),
3205        // sql_quantizer_59
3206        ("SELECT customer_item_list_id, customer_id FROM customer_item_list WHERE type = wishlist AND customer_id = ? AND visitor_id IS ? UNION SELECT customer_item_list_id, customer_id FROM customer_item_list WHERE type = wishlist AND customer_id IS ? AND visitor_id = \"AA0DKTGEM6LRN3WWPZ01Q61E3J7ROX7O\" ORDER BY customer_id DESC", "SELECT customer_item_list_id, customer_id FROM customer_item_list WHERE type = wishlist AND customer_id = ? AND visitor_id IS ? UNION SELECT customer_item_list_id, customer_id FROM customer_item_list WHERE type = wishlist AND customer_id IS ? AND visitor_id = ? ORDER BY customer_id DESC"),
3207        // sql_quantizer_60
3208        ("update Orders set created = \"2019-05-24 00:26:17\", gross = 30.28, payment_type = \"eventbrite\", mg_fee = \"3.28\", fee_collected = \"3.28\", event = 59366262, status = \"10\", survey_type = 'direct', tx_time_limit = 480, invite = \"\", ip_address = \"69.215.148.82\", currency = 'USD', gross_USD = \"30.28\", tax_USD = 0.00, journal_activity_id = 4044659812798558774, eb_tax = 0.00, eb_tax_USD = 0.00, cart_uuid = \"160b450e7df511e9810e0a0c06de92f8\", changed = '2019-05-24 00:26:17' where id = ?", "update Orders set created = ? gross = ? payment_type = ? mg_fee = ? fee_collected = ? event = ? status = ? survey_type = ? tx_time_limit = ? invite = ? ip_address = ? currency = ? gross_USD = ? tax_USD = ? journal_activity_id = ? eb_tax = ? eb_tax_USD = ? cart_uuid = ? changed = ? where id = ?"),
3209        // sql_quantizer_61
3210        ("update Attendees set email = '626837270@qq.com', first_name = \"贺新春送猪福加企鹅1054948000领98綵斟\", last_name = '王子198442com体验猪多优惠', journal_activity_id = 4246684839261125564, changed = \"2019-05-24 00:26:22\" where id = 123", "update Attendees set email = ? first_name = ? last_name = ? journal_activity_id = ? changed = ? where id = ?"),
3211        // sql_quantizer_62
3212        ("SELECT\r\n\t                CodiFormacio\r\n\t                ,DataInici\r\n\t                ,DataFi\r\n\t                ,Tipo\r\n\t                ,CodiTecnicFormador\r\n\t                ,p.nombre AS TutorNombre\r\n\t                ,p.mail AS TutorMail\r\n\t                ,Sessions.Direccio\r\n\t                ,Sessions.NomEmpresa\r\n\t                ,Sessions.Telefon\r\n                FROM\r\n                ----------------------------\r\n                (SELECT\r\n\t                CodiFormacio\r\n\t                ,case\r\n\t                   when ModalitatSessio = '1' then 'Presencial'--Teoria\r\n\t                   when ModalitatSessio = '2' then 'Presencial'--Practica\r\n\t                   when ModalitatSessio = '3' then 'Online'--Tutoria\r\n                       when ModalitatSessio = '4' then 'Presencial'--Examen\r\n\t                   ELSE 'Presencial'\r\n\t                end as Tipo\r\n\t                ,ModalitatSessio\r\n\t                ,DataInici\r\n\t                ,DataFi\r\n                     ,NomEmpresa\r\n\t                ,Telefon\r\n\t                ,CodiTecnicFormador\r\n\t                ,CASE\r\n\t                   WHEn EsAltres = 1 then FormacioLlocImparticioDescripcio\r\n\t                   else Adreca + ' - ' + CodiPostal + ' ' + Poblacio\r\n\t                end as Direccio\r\n\t\r\n                FROM Consultas.dbo.View_AsActiva__FormacioSessions_InfoLlocImparticio) AS Sessions\r\n                ----------------------------------------\r\n                LEFT JOIN Consultas.dbo.View_AsActiva_Operari AS o\r\n\t                ON o.CodiOperari = Sessions.CodiTecnicFormador\r\n                LEFT JOIN MainAPP.dbo.persona AS p\r\n\t                ON 'preven\\' + o.codioperari = p.codi\r\n                WHERE Sessions.CodiFormacio = 'F00000017898'", "SELECT CodiFormacio, DataInici, DataFi, Tipo, CodiTecnicFormador, p.nombre, p.mail, Sessions.Direccio, Sessions.NomEmpresa, Sessions.Telefon FROM ( SELECT CodiFormacio, case when ModalitatSessio = ? then ? when ModalitatSessio = ? then ? when ModalitatSessio = ? then ? when ModalitatSessio = ? then ? ELSE ? end, ModalitatSessio, DataInici, DataFi, NomEmpresa, Telefon, CodiTecnicFormador, CASE WHEn EsAltres = ? then FormacioLlocImparticioDescripcio else Adreca + ? + CodiPostal + ? + Poblacio end FROM Consultas.dbo.View_AsActiva__FormacioSessions_InfoLlocImparticio ) LEFT JOIN Consultas.dbo.View_AsActiva_Operari ON o.CodiOperari = Sessions.CodiTecnicFormador LEFT JOIN MainAPP.dbo.persona ON ? + o.codioperari = p.codi WHERE Sessions.CodiFormacio = ?"),
3213        // sql_quantizer_63
3214        ("SELECT * FROM foo LEFT JOIN bar ON 'backslash\\' = foo.b WHERE foo.name = 'String'", "SELECT * FROM foo LEFT JOIN bar ON ? = foo.b WHERE foo.name = ?"),
3215        // sql_quantizer_64
3216        ("SELECT * FROM foo LEFT JOIN bar ON 'backslash\\' = foo.b LEFT JOIN bar2 ON 'backslash2\\' = foo.b2 WHERE foo.name = 'String'", "SELECT * FROM foo LEFT JOIN bar ON ? = foo.b LEFT JOIN bar2 ON ? = foo.b2 WHERE foo.name = ?"),
3217        // sql_quantizer_65
3218        ("SELECT * FROM foo LEFT JOIN bar ON 'embedded ''quote'' in string' = foo.b WHERE foo.name = 'String'", "SELECT * FROM foo LEFT JOIN bar ON ? = foo.b WHERE foo.name = ?"),
3219        // sql_quantizer_66
3220        ("SELECT * FROM foo LEFT JOIN bar ON 'embedded \\'quote\\' in string' = foo.b WHERE foo.name = 'String'", "SELECT * FROM foo LEFT JOIN bar ON ? = foo.b WHERE foo.name = ?"),
3221        // sql_quantizer_67
3222        ("SELECT org_id,metric_key,metric_type,interval FROM metrics_metadata WHERE org_id = ? AND metric_key = ANY(ARRAY[?,?,?,?,?])", "SELECT org_id, metric_key, metric_type, interval FROM metrics_metadata WHERE org_id = ? AND metric_key = ANY ( ARRAY [ ? ] )"),
3223        // sql_quantizer_68
3224        ("SELECT wp_woocommerce_order_items.order_id As No_Commande\n\t\t\tFROM  wp_woocommerce_order_items\n\t\t\tLEFT JOIN\n\t\t\t\t(\n\t\t\t\t\tSELECT meta_value As Prenom\n\t\t\t\t\tFROM wp_postmeta\n\t\t\t\t\tWHERE meta_key = '_shipping_first_name'\n\t\t\t\t) AS a\n\t\t\tON wp_woocommerce_order_items.order_id = a.post_id\n\t\t\tWHERE  wp_woocommerce_order_items.order_id =2198", "SELECT wp_woocommerce_order_items.order_id FROM wp_woocommerce_order_items LEFT JOIN ( SELECT meta_value FROM wp_postmeta WHERE meta_key = ? ) ON wp_woocommerce_order_items.order_id = a.post_id WHERE wp_woocommerce_order_items.order_id = ?"),
3225        // sql_quantizer_69
3226        ("SELECT a :: VARCHAR(255) FROM foo WHERE foo.name = 'String'", "SELECT a :: VARCHAR ( ? ) FROM foo WHERE foo.name = ?"),
3227        // sql_quantizer_70
3228        ("SELECT MIN(`scoped_49a39c4cc9ae4fdda07bcf49e99f8224`.`scoped_8720d2c0e0824ec2910ab9479085839c`) AS `MIN_BECR_DATE_CREATED` FROM (SELECT `49a39c4cc9ae4fdda07bcf49e99f8224`.`submittedOn` AS `scoped_8720d2c0e0824ec2910ab9479085839c`, `49a39c4cc9ae4fdda07bcf49e99f8224`.`domain` AS `scoped_847e4dcfa1c54d72aad6dbeb231c46de`, `49a39c4cc9ae4fdda07bcf49e99f8224`.`eventConsumer` AS `scoped_7b2f7b8da15646d1b75aa03901460eb2`, `49a39c4cc9ae4fdda07bcf49e99f8224`.`eventType` AS `scoped_77a1b9308b384a9391b69d24335ba058` FROM (`SorDesignTime`.`businessEventConsumerRegistry_947a74dad4b64be9847d67f466d26f5e` AS `49a39c4cc9ae4fdda07bcf49e99f8224`) WHERE (`49a39c4cc9ae4fdda07bcf49e99f8224`.`systemData.ClientID`) = ('35c1ccc0-a83c-4812-a189-895e9d4dd223')) AS `scoped_49a39c4cc9ae4fdda07bcf49e99f8224` WHERE ((`scoped_49a39c4cc9ae4fdda07bcf49e99f8224`.`scoped_847e4dcfa1c54d72aad6dbeb231c46de`) = ('Benefits') AND ((`scoped_49a39c4cc9ae4fdda07bcf49e99f8224`.`scoped_7b2f7b8da15646d1b75aa03901460eb2`) = ('benefits') AND (`scoped_49a39c4cc9ae4fdda07bcf49e99f8224`.`scoped_77a1b9308b384a9391b69d24335ba058`) = ('DMXSync'))); ", "SELECT MIN ( scoped_49a39c4cc9ae4fdda07bcf49e99f8224 . scoped_8720d2c0e0824ec2910ab9479085839c ) FROM ( SELECT 49a39c4cc9ae4fdda07bcf49e99f8224 . submittedOn, 49a39c4cc9ae4fdda07bcf49e99f8224 . domain, 49a39c4cc9ae4fdda07bcf49e99f8224 . eventConsumer, 49a39c4cc9ae4fdda07bcf49e99f8224 . eventType FROM ( SorDesignTime . businessEventConsumerRegistry_947a74dad4b64be9847d67f466d26f5e ) WHERE ( 49a39c4cc9ae4fdda07bcf49e99f8224 . systemData.ClientID ) = ( ? ) ) WHERE ( ( scoped_49a39c4cc9ae4fdda07bcf49e99f8224 . scoped_847e4dcfa1c54d72aad6dbeb231c46de ) = ( ? ) AND ( ( scoped_49a39c4cc9ae4fdda07bcf49e99f8224 . scoped_7b2f7b8da15646d1b75aa03901460eb2 ) = ( ? ) AND ( scoped_49a39c4cc9ae4fdda07bcf49e99f8224 . scoped_77a1b9308b384a9391b69d24335ba058 ) = ( ? ) ) )"),
3229        // sql_quantizer_71
3230        ("{call px_cu_se_security_pg.sps_get_my_accounts_count(?, ?, ?, ?)}", "{ call px_cu_se_security_pg.sps_get_my_accounts_count ( ? ) }"),
3231        // sql_quantizer_72
3232        ("{call px_cu_se_security_pg.sps_get_my_accounts_count(1, 2, 'one', 'two')};", "{ call px_cu_se_security_pg.sps_get_my_accounts_count ( ? ) }"),
3233        // sql_quantizer_73
3234        ("{call curly_fun('{{', '}}', '}', '}')};", "{ call curly_fun ( ? ) }"),
3235        // sql_quantizer_74
3236        ("SELECT id, name FROM emp WHERE name LIKE {fn UCASE('Smith')}", "SELECT id, name FROM emp WHERE name LIKE ?"),
3237        // sql_quantizer_75
3238        ("select users.custom #- '{a,b}' from users", "select users.custom"),
3239        // sql_quantizer_76
3240        ("select users.custom #> '{a,b}' from users", "select users.custom"),
3241        // sql_quantizer_77
3242        ("select users.custom #>> '{a,b}' from users", "select users.custom"),
3243        // sql_quantizer_78
3244        ("SELECT a FROM foo WHERE value<@name", "SELECT a FROM foo WHERE value < @name"),
3245        // sql_quantizer_79
3246        ("SELECT @@foo", "SELECT @@foo"),
3247        // sql_quantizer_80
3248        ("DROP TABLE IF EXISTS django_site;\nDROP TABLE IF EXISTS knowledgebase_article;\n\nCREATE TABLE django_site (\n    id integer PRIMARY KEY,\n    domain character varying(100) NOT NULL,\n    name character varying(50) NOT NULL,\n    uuid uuid NOT NULL,\n    disabled boolean DEFAULT false NOT NULL\n);\n\nCREATE TABLE knowledgebase_article (\n    id integer PRIMARY KEY,\n    title character varying(255) NOT NULL,\n    site_id integer NOT NULL,\n    CONSTRAINT knowledgebase_article_site_id_fkey FOREIGN KEY (site_id) REFERENCES django_site(id)\n);\n\nINSERT INTO django_site(id, domain, name, uuid, disabled) VALUES (1, 'foo.domain', 'Foo', 'cb4776c1-edf3-4041-96a8-e152f5ae0f91', false);\nINSERT INTO knowledgebase_article(id, title, site_id) VALUES(1, 'title', 1);", "DROP TABLE IF EXISTS django_site DROP TABLE IF EXISTS knowledgebase_article CREATE TABLE django_site ( id integer PRIMARY KEY, domain character varying ( ? ) NOT ? name character varying ( ? ) NOT ? uuid uuid NOT ? disabled boolean DEFAULT ? NOT ? ) CREATE TABLE knowledgebase_article ( id integer PRIMARY KEY, title character varying ( ? ) NOT ? site_id integer NOT ? CONSTRAINT knowledgebase_article_site_id_fkey FOREIGN KEY ( site_id ) REFERENCES django_site ( id ) ) INSERT INTO django_site ( id, domain, name, uuid, disabled ) VALUES ( ? ) INSERT INTO knowledgebase_article ( id, title, site_id ) VALUES ( ? )"),
3249        // sql_quantizer_81
3250        ("\nSELECT set_config('foo.bar', (SELECT foo.bar FROM sometable WHERE sometable.uuid = %(some_id)s)::text, FALSE);\nSELECT\n    othertable.id,\n    othertable.title\nFROM othertable\nINNER JOIN sometable ON sometable.id = othertable.site_id\nWHERE\n    sometable.uuid = %(some_id)s\nLIMIT 1\n;", "SELECT set_config ( ? ( SELECT foo.bar FROM sometable WHERE sometable.uuid = ? ) :: text, ? ) SELECT othertable.id, othertable.title FROM othertable INNER JOIN sometable ON sometable.id = othertable.site_id WHERE sometable.uuid = ? LIMIT ?"),
3251        // sql_quantizer_82
3252        ("CREATE OR REPLACE FUNCTION pg_temp.sequelize_upsert(OUT created boolean, OUT primary_key text) AS $func$ BEGIN INSERT INTO \"school\" (\"id\",\"organization_id\",\"name\",\"created_at\",\"updated_at\") VALUES ('dc4e9444-d7c9-40a9-bcef-68e4cc594e61','ec647f56-f27a-49a1-84af-021ad0a19f21','Test','2021-03-31 16:30:43.915 +00:00','2021-03-31 16:30:43.915 +00:00'); created := true; EXCEPTION WHEN unique_violation THEN UPDATE \"school\" SET \"id\"='dc4e9444-d7c9-40a9-bcef-68e4cc594e61',\"organization_id\"='ec647f56-f27a-49a1-84af-021ad0a19f21',\"name\"='Test',\"updated_at\"='2021-03-31 16:30:43.915 +00:00' WHERE (\"id\" = 'dc4e9444-d7c9-40a9-bcef-68e4cc594e61'); created := false; END; $func$ LANGUAGE plpgsql; SELECT * FROM pg_temp.sequelize_upsert();", "CREATE OR REPLACE FUNCTION pg_temp.sequelize_upsert ( OUT created boolean, OUT primary_key text ) LANGUAGE plpgsql SELECT * FROM pg_temp.sequelize_upsert ( )"),
3253        // sql_quantizer_83
3254        ("INSERT INTO table (field1, field2) VALUES (1, $$someone's string123$with other things$$)", "INSERT INTO table ( field1, field2 ) VALUES ( ? )"),
3255        // sql_quantizer_84
3256        ("INSERT INTO table (field1) VALUES ($some tag$this text confuses$some other text$some ta not quite$some tag$)", "INSERT INTO table ( field1 ) VALUES ( ? )"),
3257        // sql_quantizer_85
3258        ("INSERT INTO table (field1) VALUES ($tag$random \\wqejks \"sadads' text$tag$)", "INSERT INTO table ( field1 ) VALUES ( ? )"),
3259        // sql_quantizer_86
3260        ("SELECT nspname FROM pg_class where nspname !~ '.*toIgnore.*'", "SELECT nspname FROM pg_class where nspname !~ ?"),
3261        // sql_quantizer_87
3262        ("SELECT nspname FROM pg_class where nspname !~* '.*toIgnoreInsensitive.*'", "SELECT nspname FROM pg_class where nspname !~* ?"),
3263        // sql_quantizer_88
3264        ("SELECT nspname FROM pg_class where nspname ~ '.*matching.*'", "SELECT nspname FROM pg_class where nspname ~ ?"),
3265        // sql_quantizer_89
3266        ("SELECT nspname FROM pg_class where nspname ~* '.*matchingInsensitive.*'", "SELECT nspname FROM pg_class where nspname ~* ?"),
3267        // sql_quantizer_90
3268        ("SELECT * FROM dbo.Items WHERE id = 1 or /*!obfuscation*/ 1 = 1", "SELECT * FROM dbo.Items WHERE id = ? or ? = ?"),
3269        // sql_quantizer_91
3270        ("SELECT * FROM Items WHERE id = -1 OR id = -01 OR id = -108 OR id = -.018 OR id = -.08 OR id = -908129", "SELECT * FROM Items WHERE id = ? OR id = ? OR id = ? OR id = ? OR id = ? OR id = ?"),
3271        // sql_quantizer_92
3272        ("USING $09 SELECT", "USING ? SELECT"),
3273        // sql_quantizer_93
3274        ("USING - SELECT", "USING - SELECT"),
3275        // sql_cassandra_0
3276        ("select key, status, modified from org_check_run where org_id = %s and check in (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", "select key, status, modified from org_check_run where org_id = ? and check in ( ? )"),
3277        // sql_cassandra_1
3278        ("select key, status, modified from org_check_run where org_id = %s and check in (%s, %s, %s)", "select key, status, modified from org_check_run where org_id = ? and check in ( ? )"),
3279        // sql_cassandra_2
3280        ("select key, status, modified from org_check_run where org_id = %s and check in (%s , %s , %s )", "select key, status, modified from org_check_run where org_id = ? and check in ( ? )"),
3281        // sql_cassandra_3
3282        ("select key, status, modified from org_check_run where org_id = %s and check = %s", "select key, status, modified from org_check_run where org_id = ? and check = ?"),
3283        // sql_cassandra_4
3284        ("SELECT timestamp, processes FROM process_snapshot.minutely WHERE org_id = ? AND host = ? AND timestamp >= ? AND timestamp <= ?", "SELECT timestamp, processes FROM process_snapshot.minutely WHERE org_id = ? AND host = ? AND timestamp >= ? AND timestamp <= ?"),
3285        // sql_cassandra_5
3286        ("SELECT count(*) AS totcount FROM (SELECT \"c1\", \"c2\",\"c3\",\"c4\",\"c5\",\"c6\",\"c7\",\"c8\", \"c9\", \"c10\",\"c11\",\"c12\",\"c13\",\"c14\", \"c15\",\"c16\",\"c17\",\"c18\", \"c19\",\"c20\",\"c21\",\"c22\",\"c23\", \"c24\",\"c25\",\"c26\", \"c27\" FROM (SELECT bar.y AS \"c2\", foo.x AS \"c3\", foo.z AS \"c4\", DECODE(foo.a, NULL,NULL, foo.a ||?|| foo.b) AS \"c5\" , foo.c AS \"c6\", bar.d AS \"c1\", bar.e AS \"c7\", bar.f AS \"c8\", bar.g AS \"c9\", TO_DATE(TO_CHAR(TO_DATE(bar.h,?),?),?) AS \"c10\", TO_DATE(TO_CHAR(TO_DATE(bar.i,?),?),?) AS \"c11\", CASE WHEN DECODE(bar.j, NULL, TRUNC(SYSDATE) - TRUNC(TO_DATE(bar.h,?))) > ? THEN ? WHEN DECODE(bar.j, NULL, TRUNC(SYSDATE) - TRUNC(TO_DATE(bar.h,?))) > ? THEN ? WHEN DECODE(bar.j, NULL, TRUNC(SYSDATE) - TRUNC(TO_DATE(bar.h,?))) > ? THEN ? WHEN DECODE(bar.j, NULL, TRUNC(SYSDATE) - TRUNC(TO_DATE(bar.h,?))) > ? THEN ? WHEN DECODE(bar.j, NULL, TRUNC(SYSDATE) - TRUNC(TO_DATE(bar.h,?))) > ? THEN ? WHEN DECODE(bar.j, NULL, TRUNC(SYSDATE) - TRUNC(TO_DATE(bar.h,?))) > ? THEN ? ELSE NULL END AS \"c12\", DECODE(bar.j, NULL, TRUNC(SYSDATE) - TRUNC(TO_DATE(bar.h,?)),NULL) as \"c13\", bar.k AS \"c14\", bar.l ||?||bar.m AS \"c15\", DECODE(bar.n, NULL, NULL,bar.n ||?||bar.o) AS \"c16\", bar.p AS \"c17\", bar.q AS \"c18\", bar.r AS \"c19\", bar.s AS \"c20\", qux.a AS \"c21\", TO_CHAR(TO_DATE(qux.b,?),?) AS \"c22\", DECODE(qux.l,NULL,NULL, qux.l ||?||qux.m) AS \"c23\", bar.a AS \"c24\", TO_CHAR(TO_DATE(bar.j,?),?) AS \"c25\", DECODE(bar.c , ?,?,?, ?, bar.c ) AS \"c26\", bar.y AS y, bar.d, bar.d AS \"c27\" FROM blort.bar , ( SELECT * FROM (SELECT a,a,l,m,b,c, RANK() OVER (PARTITION BY c ORDER BY b DESC) RNK FROM blort.d WHERE y IN (:p)) WHERE RNK = ?) qux, blort.foo WHERE bar.c = qux.c(+) AND bar.x = foo.x AND bar.y IN (:p) and bar.x IN (:x)) )\nSELECT count(*) AS totcount FROM (SELECT \"c1\", \"c2\",\"c3\",\"c4\",\"c5\",\"c6\",\"c7\",\"c8\", \"c9\", \"c10\",\"c11\",\"c12\",\"c13\",\"c14\", \"c15\",\"c16\",\"c17\",\"c18\", \"c19\",\"c20\",\"c21\",\"c22\",\"c23\", \"c24\",\"c25\",\"c26\", \"c27\" FROM (SELECT bar.y AS \"c2\", foo.x AS \"c3\", foo.z AS \"c4\", DECODE(foo.a, NULL,NULL, foo.a ||?|| foo.b) AS \"c5\" , foo.c AS \"c6\", bar.d AS \"c1\", bar.e AS \"c7\", bar.f AS \"c8\", bar.g AS \"c9\", TO_DATE(TO_CHAR(TO_DATE(bar.h,?),?),?) AS \"c10\", TO_DATE(TO_CHAR(TO_DATE(bar.i,?),?),?) AS \"c11\", CASE WHEN DECODE(bar.j, NULL, TRUNC(SYSDATE) - TRUNC(TO_DATE(bar.h,?))) > ? THEN ? WHEN DECODE(bar.j, NULL, TRUNC(SYSDATE) - TRUNC(TO_DATE(bar.h,?))) > ? THEN ? WHEN DECODE(bar.j, NULL, TRUNC(SYSDATE) - TRUNC(TO_DATE(bar.h,?))) > ? THEN ? WHEN DECODE(bar.j, NULL, TRUNC(SYSDATE) - TRUNC(TO_DATE(bar.h,?))) > ? THEN ? WHEN DECODE(bar.j, NULL, TRUNC(SYSDATE) - TRUNC(TO_DATE(bar.h,?))) > ? THEN ? WHEN DECODE(bar.j, NULL, TRUNC(SYSDATE) - TRUNC(TO_DATE(bar.h,?))) > ? THEN ? ELSE NULL END AS \"c12\", DECODE(bar.j, NULL, TRUNC(SYSDATE) - TRUNC(TO_DATE(bar.h,?)),NULL) as \"c13\", bar.k AS \"c14\", bar.l ||?||bar.m AS \"c15\", DECODE(bar.n, NULL, NULL,bar.n ||?||bar.o) AS \"c16\", bar.p AS \"c17\", bar.q AS \"c18\", bar.r AS \"c19\", bar.s AS \"c20\", qux.a AS \"c21\", TO_CHAR(TO_DATE(qux.b,?),?) AS \"c22\", DECODE(qux.l,NULL,NULL, qux.l ||?||qux.m) AS \"c23\", bar.a AS \"c24\", TO_CHAR(TO_DATE(bar.j,?),?) AS \"c25\", DECODE(bar.c , ?,?,?, ?, bar.c ) AS \"c26\", bar.y AS y, bar.d, bar.d AS \"c27\" FROM blort.bar , ( SELECT * FROM (SELECT a,a,l,m,b,c, RANK() OVER (PARTITION BY c ORDER BY b DESC) RNK FROM blort.d WHERE y IN (:p)) WHERE RNK = ?) qux, blort.foo WHERE bar.c = qux.c(+) AND bar.x = foo.x AND bar.y IN (:p) and bar.x IN (:x)) )", "SELECT count ( * ) FROM ( SELECT c1, c2, c3, c4, c5, c6, c7, c8, c9, c10, c11, c12, c13, c14, c15, c16, c17, c18, c19, c20, c21, c22, c23, c24, c25, c26, c27 FROM ( SELECT bar.y, foo.x, foo.z, DECODE ( foo.a, ? foo.a | | ? | | foo.b ), foo.c, bar.d, bar.e, bar.f, bar.g, TO_DATE ( TO_CHAR ( TO_DATE ( bar.h, ? ) ) ), TO_DATE ( TO_CHAR ( TO_DATE ( bar.i, ? ) ) ), CASE WHEN DECODE ( bar.j, ? TRUNC ( SYSDATE ) - TRUNC ( TO_DATE ( bar.h, ? ) ) ) > ? THEN ? WHEN DECODE ( bar.j, ? TRUNC ( SYSDATE ) - TRUNC ( TO_DATE ( bar.h, ? ) ) ) > ? THEN ? WHEN DECODE ( bar.j, ? TRUNC ( SYSDATE ) - TRUNC ( TO_DATE ( bar.h, ? ) ) ) > ? THEN ? WHEN DECODE ( bar.j, ? TRUNC ( SYSDATE ) - TRUNC ( TO_DATE ( bar.h, ? ) ) ) > ? THEN ? WHEN DECODE ( bar.j, ? TRUNC ( SYSDATE ) - TRUNC ( TO_DATE ( bar.h, ? ) ) ) > ? THEN ? WHEN DECODE ( bar.j, ? TRUNC ( SYSDATE ) - TRUNC ( TO_DATE ( bar.h, ? ) ) ) > ? THEN ? ELSE ? END, DECODE ( bar.j, ? TRUNC ( SYSDATE ) - TRUNC ( TO_DATE ( bar.h, ? ) ) ), bar.k, bar.l | | ? | | bar.m, DECODE ( bar.n, ? bar.n | | ? | | bar.o ), bar.p, bar.q, bar.r, bar.s, qux.a, TO_CHAR ( TO_DATE ( qux.b, ? ) ), DECODE ( qux.l, ? qux.l | | ? | | qux.m ), bar.a, TO_CHAR ( TO_DATE ( bar.j, ? ) ), DECODE ( bar.c, ? bar.c ), bar.y, bar.d, bar.d FROM blort.bar, ( SELECT * FROM ( SELECT a, a, l, m, b, c, RANK ( ) OVER ( PARTITION BY c ORDER BY b DESC ) RNK FROM blort.d WHERE y IN ( :p ) ) WHERE RNK = ? ) qux, blort.foo WHERE bar.c = qux.c ( + ) AND bar.x = foo.x AND bar.y IN ( :p ) and bar.x IN ( :x ) ) ) SELECT count ( * ) FROM ( SELECT c1, c2, c3, c4, c5, c6, c7, c8, c9, c10, c11, c12, c13, c14, c15, c16, c17, c18, c19, c20, c21, c22, c23, c24, c25, c26, c27 FROM ( SELECT bar.y, foo.x, foo.z, DECODE ( foo.a, ? foo.a | | ? | | foo.b ), foo.c, bar.d, bar.e, bar.f, bar.g, TO_DATE ( TO_CHAR ( TO_DATE ( bar.h, ? ) ) ), TO_DATE ( TO_CHAR ( TO_DATE ( bar.i, ? ) ) ), CASE WHEN DECODE ( bar.j, ? TRUNC ( SYSDATE ) - TRUNC ( TO_DATE ( bar.h, ? ) ) ) > ? THEN ? WHEN DECODE ( bar.j, ? TRUNC ( SYSDATE ) - TRUNC ( TO_DATE ( bar.h, ? ) ) ) > ? THEN ? WHEN DECODE ( bar.j, ? TRUNC ( SYSDATE ) - TRUNC ( TO_DATE ( bar.h, ? ) ) ) > ? THEN ? WHEN DECODE ( bar.j, ? TRUNC ( SYSDATE ) - TRUNC ( TO_DATE ( bar.h, ? ) ) ) > ? THEN ? WHEN DECODE ( bar.j, ? TRUNC ( SYSDATE ) - TRUNC ( TO_DATE ( bar.h, ? ) ) ) > ? THEN ? WHEN DECODE ( bar.j, ? TRUNC ( SYSDATE ) - TRUNC ( TO_DATE ( bar.h, ? ) ) ) > ? THEN ? ELSE ? END, DECODE ( bar.j, ? TRUNC ( SYSDATE ) - TRUNC ( TO_DATE ( bar.h, ? ) ) ), bar.k, bar.l | | ? | | bar.m, DECODE ( bar.n, ? bar.n | | ? | | bar.o ), bar.p, bar.q, bar.r, bar.s, qux.a, TO_CHAR ( TO_DATE ( qux.b, ? ) ), DECODE ( qux.l, ? qux.l | | ? | | qux.m ), bar.a, TO_CHAR ( TO_DATE ( bar.j, ? ) ), DECODE ( bar.c, ? bar.c ), bar.y, bar.d, bar.d FROM blort.bar, ( SELECT * FROM ( SELECT a, a, l, m, b, c, RANK ( ) OVER ( PARTITION BY c ORDER BY b DESC ) RNK FROM blort.d WHERE y IN ( :p ) ) WHERE RNK = ? ) qux, blort.foo WHERE bar.c = qux.c ( + ) AND bar.x = foo.x AND bar.y IN ( :p ) and bar.x IN ( :x ) ) )"),
3287        // sql_parse_number_1234
3288        ("1234", "?"),
3289        // sql_parse_number_-1234
3290        ("-1234", "?"),
3291        // sql_parse_number_1234e12
3292        ("1234e12", "?"),
3293        // sql_parse_number_0xfa
3294        ("0xfa", "?"),
3295        // sql_parse_number_01234567
3296        ("01234567", "?"),
3297        // sql_parse_number_09
3298        ("09", "?"),
3299        // sql_parse_number_-01234567
3300        ("-01234567", "?"),
3301        // sql_parse_number_-012345678
3302        ("-012345678", "?"),
3303    ];
3304
3305    #[test]
3306    fn test_sql_obfuscation_suite() {
3307        let mut errors = String::new();
3308        for (i, (input, expected)) in SUITE_CASES.iter().enumerate() {
3309            let got = super::obfuscate_sql_string(input);
3310            if got != *expected {
3311                let _ = write!(
3312                    errors,
3313                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
3314                );
3315            }
3316        }
3317        assert!(errors.is_empty(), "{errors}");
3318    }
3319
3320    // {'keep_sql_alias': True}
3321    #[test]
3322    #[allow(deprecated)]
3323    fn test_suite_keep_sql_alias() {
3324        let config = SqlObfuscateConfig {
3325            keep_sql_alias: true,
3326            ..Default::default()
3327        };
3328        let cases: &[(&str, &str)] = &[
3329            // sql_keep_alias_on
3330            (
3331                "SELECT username AS person FROM users WHERE id=4",
3332                "SELECT username AS person FROM users WHERE id = ?",
3333            ),
3334        ];
3335        let mut errors = String::new();
3336        for (i, (input, expected)) in cases.iter().enumerate() {
3337            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
3338            if got != *expected {
3339                let _ = write!(
3340                    errors,
3341                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
3342                );
3343            }
3344        }
3345        assert!(errors.is_empty(), "{errors}");
3346    }
3347
3348    // {'dollar_quoted_func': True}
3349    #[test]
3350    #[allow(deprecated)]
3351    fn test_suite_dollar_quoted_func() {
3352        let config = SqlObfuscateConfig {
3353            dollar_quoted_func: true,
3354            ..Default::default()
3355        };
3356        let cases: &[(&str, &str)] = &[
3357            // sql_dollar_quoted_func_on
3358            (
3359                "SELECT $func$INSERT INTO table VALUES ('a', 1, 2)$func$ FROM users",
3360                "SELECT $func$INSERT INTO table VALUES ( ? )$func$ FROM users",
3361            ),
3362        ];
3363        let mut errors = String::new();
3364        for (i, (input, expected)) in cases.iter().enumerate() {
3365            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
3366            if got != *expected {
3367                let _ = write!(
3368                    errors,
3369                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
3370                );
3371            }
3372        }
3373        assert!(errors.is_empty(), "{errors}");
3374    }
3375
3376    // {'keep_sql_alias': True, 'dollar_quoted_func': True}
3377    #[test]
3378    #[allow(deprecated)]
3379    fn test_suite_keep_sql_alias_dollar_quoted_func() {
3380        let config = SqlObfuscateConfig {
3381            keep_sql_alias: true,
3382            dollar_quoted_func: true,
3383            ..Default::default()
3384        };
3385        let cases: &[(&str, &str)] = &[
3386            // sql_dollar_quoted_func_as
3387            ("CREATE OR REPLACE FUNCTION pg_temp.sequelize_upsert(OUT created boolean, OUT primary_key text) AS $func$ BEGIN INSERT INTO \"school\" (\"id\",\"organization_id\",\"name\",\"created_at\",\"updated_at\") VALUES ('dc4e9444-d7c9-40a9-bcef-68e4cc594e61','ec647f56-f27a-49a1-84af-021ad0a19f21','Test','2021-03-31 16:30:43.915 +00:00','2021-03-31 16:30:43.915 +00:00'); created := true; EXCEPTION WHEN unique_violation THEN UPDATE \"school\" SET \"id\"='dc4e9444-d7c9-40a9-bcef-68e4cc594e61',\"organization_id\"='ec647f56-f27a-49a1-84af-021ad0a19f21',\"name\"='Test',\"updated_at\"='2021-03-31 16:30:43.915 +00:00' WHERE (\"id\" = 'dc4e9444-d7c9-40a9-bcef-68e4cc594e61'); created := false; END; $func$ LANGUAGE plpgsql; SELECT * FROM pg_temp.sequelize_upsert();", "CREATE OR REPLACE FUNCTION pg_temp.sequelize_upsert ( OUT created boolean, OUT primary_key text ) AS $func$BEGIN INSERT INTO school ( id, organization_id, name, created_at, updated_at ) VALUES ( ? ) created := ? EXCEPTION WHEN unique_violation THEN UPDATE school SET id = ? organization_id = ? name = ? updated_at = ? WHERE ( id = ? ) created := ? END$func$ LANGUAGE plpgsql SELECT * FROM pg_temp.sequelize_upsert ( )"),
3388        ];
3389        let mut errors = String::new();
3390        for (i, (input, expected)) in cases.iter().enumerate() {
3391            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
3392            if got != *expected {
3393                let _ = write!(
3394                    errors,
3395                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
3396                );
3397            }
3398        }
3399        assert!(errors.is_empty(), "{errors}");
3400    }
3401
3402    // {'replace_digits': True}
3403    #[test]
3404    #[allow(deprecated)]
3405    fn test_suite_replace_digits() {
3406        let config = SqlObfuscateConfig {
3407            replace_digits: true,
3408            ..Default::default()
3409        };
3410        let cases: &[(&str, &str)] = &[
3411            // sql_metadata_complex_with_replace_digits
3412            ("\n/* Multi-line comment\nwith line breaks */\nWITH sales AS\n(SELECT sf2.*\n\tFROM gosalesdw28391.sls_order_method_dim AS md,\n\t\tgosalesdw1920.sls_product_dim391 AS pd190,\n\t\tgosalesdw3819.emp_employee_dim AS ed,\n\t\tgosalesdw3919.sls_sales_fact3819 AS sf2\n\tWHERE pd190.product_key = sf2.product_key\n\tAND pd190.product_number381 > 10000\n\tAND pd190.base_product_key > 30\n\tAND md.order_method_key = sf2.order_method_key8319\n\tAND md.order_method_code > 5\n\tAND ed.employee_key = sf2.employee_key\n\tAND ed.manager_code1 > 20),\ninventory3118 AS\n(SELECT if.*\n\tFROM gosalesdw1592.go_branch_dim AS bd3221,\n\tgosalesdw.dist_inventory_fact AS if\n\tWHERE if.branch_key = bd3221.branch_key\n\tAND bd3221.branch_code > 20)\nSELECT sales1828.product_key AS PROD_KEY,\nSUM(CAST (inventory3118.quantity_shipped AS BIGINT)) AS INV_SHIPPED3118,\nSUM(CAST (sales1828.quantity AS BIGINT)) AS PROD_QUANTITY,\nRANK() OVER ( ORDER BY SUM(CAST (sales1828.quantity AS BIGINT)) DESC) AS PROD_RANK\nFROM sales1828, inventory3118\nWHERE sales1828.product_key = inventory3118.product_key\nGROUP BY sales1828.product_key", "WITH sales SELECT sf?.* FROM gosalesdw?.sls_order_method_dim, gosalesdw?.sls_product_dim?, gosalesdw?.emp_employee_dim, gosalesdw?.sls_sales_fact? WHERE pd?.product_key = sf?.product_key AND pd?.product_number? > ? AND pd?.base_product_key > ? AND md.order_method_key = sf?.order_method_key? AND md.order_method_code > ? AND ed.employee_key = sf?.employee_key AND ed.manager_code? > ? ) inventory? SELECT if.* FROM gosalesdw?.go_branch_dim, gosalesdw.dist_inventory_fact WHERE if.branch_key = bd?.branch_key AND bd?.branch_code > ? ) SELECT sales?.product_key, SUM ( CAST ( inventory?.quantity_shipped ) ), SUM ( CAST ( sales?.quantity ) ), RANK ( ) OVER ( ORDER BY SUM ( CAST ( sales?.quantity ) ) DESC ) FROM sales?, inventory? WHERE sales?.product_key = inventory?.product_key GROUP BY sales?.product_key"),
3413            // sql_replace_digits_on_0
3414            ("REPLACE INTO sales_2019_07_01 (`itemID`, `date`, `qty`, `price`) VALUES ((SELECT itemID FROM item1001 WHERE `sku` = [sku]), CURDATE(), [qty], 0.00)", "REPLACE INTO sales_?_?_? ( itemID, date, qty, price ) VALUES ( ( SELECT itemID FROM item? WHERE sku = [ sku ] ), CURDATE ( ), [ qty ], ? )"),
3415            // sql_replace_digits_on_1
3416            ("SELECT ddh19.name, ddt.tags FROM dd91219.host ddh19, dd21916.host_tags ddt WHERE ddh19.id = ddt.host_id AND ddh19.org_id = 2 AND ddh19.name = 'datadog'", "SELECT ddh?.name, ddt.tags FROM dd?.host ddh?, dd?.host_tags ddt WHERE ddh?.id = ddt.host_id AND ddh?.org_id = ? AND ddh?.name = ?"),
3417            // sql_replace_digits_on_2
3418            ("SELECT ddu2.name, ddo.id10, ddk.app_key52 FROM dd3120.user ddu2, dd1931.orgs55 ddo, dd53819.keys ddk", "SELECT ddu?.name, ddo.id?, ddk.app_key? FROM dd?.user ddu?, dd?.orgs? ddo, dd?.keys ddk"),
3419            // sql_replace_digits_on_3
3420            ("SELECT daily_values1529.*, LEAST((5040000 - @runtot), value1830) AS value1830,\n(@runtot := @runtot + daily_values1529.value1830) AS total\nFROM (SELECT @runtot:=0) AS n,\ndaily_values1529 WHERE daily_values1529.subject_id = 12345 AND daily_values1592.subject_type = 'Skippity'\nAND (daily_values1529.date BETWEEN '2018-05-09' AND '2018-06-19') HAVING value >= 0 ORDER BY date", "SELECT daily_values?.*, LEAST ( ( ? - @runtot ), value? ), ( @runtot := @runtot + daily_values?.value? ) FROM ( SELECT @runtot := ? ), daily_values? WHERE daily_values?.subject_id = ? AND daily_values?.subject_type = ? AND ( daily_values?.date BETWEEN ? AND ? ) HAVING value >= ? ORDER BY date"),
3421            // sql_replace_digits_on_4
3422            ("WITH\nsales AS\n(SELECT sf2.*\n\tFROM gosalesdw28391.sls_order_method_dim AS md,\n\t\tgosalesdw1920.sls_product_dim391 AS pd190,\n\t\tgosalesdw3819.emp_employee_dim AS ed,\n\t\tgosalesdw3919.sls_sales_fact3819 AS sf2\n\tWHERE pd190.product_key = sf2.product_key\n\tAND pd190.product_number381 > 10000\n\tAND pd190.base_product_key > 30\n\tAND md.order_method_key = sf2.order_method_key8319\n\tAND md.order_method_code > 5\n\tAND ed.employee_key = sf2.employee_key\n\tAND ed.manager_code1 > 20),\ninventory3118 AS\n(SELECT if.*\n\tFROM gosalesdw1592.go_branch_dim AS bd3221,\n\tgosalesdw.dist_inventory_fact AS if\n\tWHERE if.branch_key = bd3221.branch_key\n\tAND bd3221.branch_code > 20)\nSELECT sales1828.product_key AS PROD_KEY,\nSUM(CAST (inventory3118.quantity_shipped AS BIGINT)) AS INV_SHIPPED3118,\nSUM(CAST (sales1828.quantity AS BIGINT)) AS PROD_QUANTITY,\nRANK() OVER ( ORDER BY SUM(CAST (sales1828.quantity AS BIGINT)) DESC) AS PROD_RANK\nFROM sales1828, inventory3118\nWHERE sales1828.product_key = inventory3118.product_key\nGROUP BY sales1828.product_key", "WITH sales SELECT sf?.* FROM gosalesdw?.sls_order_method_dim, gosalesdw?.sls_product_dim?, gosalesdw?.emp_employee_dim, gosalesdw?.sls_sales_fact? WHERE pd?.product_key = sf?.product_key AND pd?.product_number? > ? AND pd?.base_product_key > ? AND md.order_method_key = sf?.order_method_key? AND md.order_method_code > ? AND ed.employee_key = sf?.employee_key AND ed.manager_code? > ? ) inventory? SELECT if.* FROM gosalesdw?.go_branch_dim, gosalesdw.dist_inventory_fact WHERE if.branch_key = bd?.branch_key AND bd?.branch_code > ? ) SELECT sales?.product_key, SUM ( CAST ( inventory?.quantity_shipped ) ), SUM ( CAST ( sales?.quantity ) ), RANK ( ) OVER ( ORDER BY SUM ( CAST ( sales?.quantity ) ) DESC ) FROM sales?, inventory? WHERE sales?.product_key = inventory?.product_key GROUP BY sales?.product_key"),
3423            // sql_table_finder_replace_digits_0
3424            ("select * from users where id = 42", "select * from users where id = ?"),
3425            // sql_table_finder_replace_digits_1
3426            ("select * from `backslashes` where id = 42", "select * from backslashes where id = ?"),
3427            // sql_table_finder_replace_digits_2
3428            ("select * from \"double-quotes\" where id = 42", "select * from double-quotes where id = ?"),
3429            // sql_table_finder_replace_digits_3
3430            ("SELECT host, status FROM ec2_status WHERE org_id = 42", "SELECT host, status FROM ec?_status WHERE org_id = ?"),
3431            // sql_table_finder_replace_digits_4
3432            ("SELECT * FROM (SELECT * FROM nested_table)", "SELECT * FROM ( SELECT * FROM nested_table )"),
3433            // sql_table_finder_replace_digits_5
3434            ("   -- get user \n--\n select * \n   from users \n    where\n       id = 214325346    ", "select * from users where id = ?"),
3435            // sql_table_finder_replace_digits_6
3436            ("SELECT articles.* FROM articles WHERE articles.id = 1 LIMIT 1, 20", "SELECT articles.* FROM articles WHERE articles.id = ? LIMIT ?"),
3437            // sql_table_finder_replace_digits_7
3438            ("UPDATE user_dash_pref SET json_prefs = %(json_prefs)s, modified = '2015-08-27 22:10:32.492912' WHERE user_id = %(user_id)s AND url = %(url)s", "UPDATE user_dash_pref SET json_prefs = ? modified = ? WHERE user_id = ? AND url = ?"),
3439            // sql_table_finder_replace_digits_8
3440            ("SELECT DISTINCT host.id AS host_id FROM host JOIN host_alias ON host_alias.host_id = host.id WHERE host.org_id = %(org_id_1)s AND host.name NOT IN (%(name_1)s) AND host.name IN (%(name_2)s, %(name_3)s, %(name_4)s, %(name_5)s)", "SELECT DISTINCT host.id FROM host JOIN host_alias ON host_alias.host_id = host.id WHERE host.org_id = ? AND host.name NOT IN ( ? ) AND host.name IN ( ? )"),
3441            // sql_table_finder_replace_digits_9
3442            ("update Orders set created = \"2019-05-24 00:26:17\", gross = 30.28, payment_type = \"eventbrite\", mg_fee = \"3.28\", fee_collected = \"3.28\", event = 59366262, status = \"10\", survey_type = 'direct', tx_time_limit = 480, invite = \"\", ip_address = \"69.215.148.82\", currency = 'USD', gross_USD = \"30.28\", tax_USD = 0.00, journal_activity_id = 4044659812798558774, eb_tax = 0.00, eb_tax_USD = 0.00, cart_uuid = \"160b450e7df511e9810e0a0c06de92f8\", changed = '2019-05-24 00:26:17' where id = ?", "update Orders set created = ? gross = ? payment_type = ? mg_fee = ? fee_collected = ? event = ? status = ? survey_type = ? tx_time_limit = ? invite = ? ip_address = ? currency = ? gross_USD = ? tax_USD = ? journal_activity_id = ? eb_tax = ? eb_tax_USD = ? cart_uuid = ? changed = ? where id = ?"),
3443            // sql_table_finder_replace_digits_10
3444            ("SELECT * FROM clients WHERE (clients.first_name = 'Andy') LIMIT 1 BEGIN INSERT INTO clients (created_at, first_name, locked, orders_count, updated_at) VALUES ('2011-08-30 05:22:57', 'Andy', 1, NULL, '2011-08-30 05:22:57') COMMIT", "SELECT * FROM clients WHERE ( clients.first_name = ? ) LIMIT ? BEGIN INSERT INTO clients ( created_at, first_name, locked, orders_count, updated_at ) VALUES ( ? ) COMMIT"),
3445            // sql_table_finder_replace_digits_11
3446            ("DELETE FROM table WHERE table.a=1", "DELETE FROM table WHERE table.a = ?"),
3447            // sql_table_finder_replace_digits_12
3448            ("SELECT wp_woocommerce_order_items.order_id FROM wp_woocommerce_order_items LEFT JOIN ( SELECT meta_value FROM wp_postmeta WHERE meta_key = ? ) ON wp_woocommerce_order_items.order_id = a.post_id WHERE wp_woocommerce_order_items.order_id = ?", "SELECT wp_woocommerce_order_items.order_id FROM wp_woocommerce_order_items LEFT JOIN ( SELECT meta_value FROM wp_postmeta WHERE meta_key = ? ) ON wp_woocommerce_order_items.order_id = a.post_id WHERE wp_woocommerce_order_items.order_id = ?"),
3449            // sql_table_finder_replace_digits_13
3450            ("REPLACE INTO sales_2019_07_01 (`itemID`, `date`, `qty`, `price`) VALUES ((SELECT itemID FROM item1001 WHERE `sku` = [sku]), CURDATE(), [qty], 0.00)", "REPLACE INTO sales_?_?_? ( itemID, date, qty, price ) VALUES ( ( SELECT itemID FROM item? WHERE sku = [ sku ] ), CURDATE ( ), [ qty ], ? )"),
3451            // sql_table_finder_replace_digits_14
3452            ("SELECT name FROM people WHERE person_id = -1", "SELECT name FROM people WHERE person_id = ?"),
3453            // sql_table_finder_replace_digits_15
3454            ("select * from test where !is_good;", "select * from test where ! is_good"),
3455            // sql_table_finder_replace_digits_16
3456            ("select * from test where ! is_good;", "select * from test where ! is_good"),
3457            // sql_table_finder_replace_digits_17
3458            ("select * from test where !45;", "select * from test where ! ?"),
3459            // sql_table_finder_replace_digits_18
3460            ("select * from test where !(select is_good from good_things);", "select * from test where ! ( select is_good from good_things )"),
3461            // sql_table_finder_replace_digits_19
3462            ("select * from test where !'weird_query'", "select * from test where ! ?"),
3463            // sql_table_finder_replace_digits_20
3464            ("select * from test where !\"weird_query\"", "select * from test where ! weird_query"),
3465            // sql_table_finder_replace_digits_21
3466            ("select * from test where !`weird_query`", "select * from test where ! weird_query"),
3467            // sql_table_finder_replace_digits_22
3468            ("select !- 2", "select ! - ?"),
3469            // sql_table_finder_replace_digits_23
3470            ("select !+2", "select ! + ?"),
3471            // sql_table_finder_replace_digits_24
3472            ("select * from test where !- 2", "select * from test where ! - ?"),
3473            // sql_table_finder_replace_digits_25
3474            ("select count(*) as `count(*)` from test", "select count ( * ) from test"),
3475            // sql_table_finder_replace_digits_26
3476            ("SELECT age as `age}` FROM profile", "SELECT age FROM profile"),
3477            // sql_table_finder_replace_digits_27
3478            ("SELECT age as `age``}` FROM profile", "SELECT age FROM profile"),
3479            // sql_table_finder_replace_digits_28
3480            ("SELECT * from users where user_id =:0_USER", "SELECT * from users where user_id = :0_USER"),
3481        ];
3482        let mut errors = String::new();
3483        for (i, (input, expected)) in cases.iter().enumerate() {
3484            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
3485            if got != *expected {
3486                let _ = write!(
3487                    errors,
3488                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
3489                );
3490            }
3491        }
3492        assert!(errors.is_empty(), "{errors}");
3493    }
3494
3495    // {'keep_sql_alias': True, 'dollar_quoted_func': True, 'keep_null': True, 'keep_boolean': True,
3496    // 'keep_positional_parameter': True, 'keep_trailing_semicolon': True,
3497    // 'keep_identifier_quotation': True, 'replace_bind_parameter': True,
3498    // 'remove_space_between_parentheses': True, 'keep_json_path': True, 'replace_digits': True}
3499    #[test]
3500    #[allow(deprecated)]
3501    fn test_suite_all_flags() {
3502        let config = SqlObfuscateConfig {
3503            keep_sql_alias: true,
3504            dollar_quoted_func: true,
3505            keep_null: true,
3506            keep_boolean: true,
3507            keep_positional_parameter: true,
3508            keep_trailing_semicolon: true,
3509            keep_identifier_quotation: true,
3510            replace_bind_parameter: true,
3511            remove_space_between_parentheses: true,
3512            keep_json_path: true,
3513            replace_digits: true,
3514            ..Default::default()
3515        };
3516        let cases: &[(&str, &str)] = &[
3517            // sql_fuzzing_1230223853
3518            ("$2", "?"),
3519            // sql_fuzzing_3056568399
3520            ("(2", "( ?"),
3521            // sql_fuzzing_2600047278
3522            (";ჸ", "ჸ"),
3523            // sql_fuzzing_1323053175
3524            (";ჸ", "ჸ"),
3525            // sql_fuzzing_726138257
3526            ("@2", "@?"),
3527            // sql_fuzzing_3590332207
3528            ("@C", "@C"),
3529            // sql_fuzzing_572710742
3530            ("\"\"\"\"", "\""),
3531            // sql_fuzzing_3189077130
3532            ("@ჸ2", "@ჸ?"),
3533            // sql_fuzzing_832034588
3534            ("\"0\"", "0"),
3535        ];
3536        let mut errors = String::new();
3537        for (i, (input, expected)) in cases.iter().enumerate() {
3538            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
3539            if got != *expected {
3540                let _ = write!(
3541                    errors,
3542                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
3543                );
3544            }
3545        }
3546        assert!(errors.is_empty(), "{errors}");
3547    }
3548
3549    // {'dbms': 'mssql'}
3550    #[test]
3551    #[allow(deprecated)]
3552    fn test_suite_mssql() {
3553        let config = SqlObfuscateConfig::default();
3554        let cases: &[(&str, &str)] = &[
3555            // sql_single_dollar_identifier_merge
3556            ("\n\tMERGE INTO Employees AS target\n\tUSING EmployeeUpdates AS source\n\tON (target.EmployeeID = source.EmployeeID)\n\tWHEN MATCHED THEN\n\t\tUPDATE SET\n\t\t\ttarget.Name = source.Name\n\tWHEN NOT MATCHED BY TARGET THEN\n\t\tINSERT (EmployeeID, Name)\n\t\tVALUES (source.EmployeeID, source.Name)\n\tWHEN NOT MATCHED BY SOURCE THEN\n\t\tDELETE\n\tOUTPUT $action, inserted.*, deleted.*;\n\t", "MERGE INTO Employees USING EmployeeUpdates ON ( target.EmployeeID = source.EmployeeID ) WHEN MATCHED THEN UPDATE SET target.Name = source.Name WHEN NOT MATCHED BY TARGET THEN INSERT ( EmployeeID, Name ) VALUES ( source.EmployeeID, source.Name ) WHEN NOT MATCHED BY SOURCE THEN DELETE OUTPUT $action, inserted.*, deleted.*"),
3557            // sql_dbms_sqlserver_global_temp
3558            ("select * from ##ThisIsAGlobalTempTable where id = 1", "select * from ##ThisIsAGlobalTempTable where id = ?"),
3559            // sql_dbms_sqlserver_temp
3560            ("select * from dbo.#ThisIsATempTable where id = 1", "select * from dbo.#ThisIsATempTable where id = ?"),
3561            // sql_dbms_sqlserver_brackets
3562            ("SELECT * from [db_users] where [id] = @1", "SELECT * from db_users where id = @1"),
3563        ];
3564        let mut errors = String::new();
3565        for (i, (input, expected)) in cases.iter().enumerate() {
3566            let got = super::obfuscate_sql(input, &config, DbmsKind::Mssql);
3567            if got != *expected {
3568                let _ = write!(
3569                    errors,
3570                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
3571                );
3572            }
3573        }
3574        assert!(errors.is_empty(), "{errors}");
3575    }
3576
3577    // {'dbms': 'postgresql'}
3578    #[test]
3579    #[allow(deprecated)]
3580    fn test_suite_postgresql() {
3581        let config = SqlObfuscateConfig::default();
3582        let cases: &[(&str, &str)] = &[
3583            // sql_pg_json_operators_0
3584            (
3585                "select users.custom #> '{a,b}' from users",
3586                "select users.custom #> ? from users",
3587            ),
3588            // sql_pg_json_operators_1
3589            (
3590                "select users.custom #>> '{a,b}' from users",
3591                "select users.custom #>> ? from users",
3592            ),
3593            // sql_pg_json_operators_2
3594            (
3595                "select users.custom #- '{a,b}' from users",
3596                "select users.custom #- ? from users",
3597            ),
3598            // sql_pg_json_operators_3
3599            (
3600                "select users.custom -> 'foo' from users",
3601                "select users.custom -> ? from users",
3602            ),
3603            // sql_pg_json_operators_4
3604            (
3605                "select users.custom ->> 'foo' from users",
3606                "select users.custom ->> ? from users",
3607            ),
3608            // sql_pg_json_operators_5
3609            (
3610                "select * from users where user.custom @> '{a,b}'",
3611                "select * from users where user.custom @> ?",
3612            ),
3613            // sql_pg_json_operators_6
3614            (
3615                "SELECT a FROM foo WHERE value<@name",
3616                "SELECT a FROM foo WHERE value <@ name",
3617            ),
3618            // sql_pg_json_operators_7
3619            (
3620                "select * from users where user.custom ? 'foo'",
3621                "select * from users where user.custom ? ?",
3622            ),
3623            // sql_pg_json_operators_8
3624            (
3625                "select * from users where user.custom ?| array [ '1', '2' ]",
3626                "select * from users where user.custom ?| array [ ? ]",
3627            ),
3628            // sql_pg_json_operators_9
3629            (
3630                "select * from users where user.custom ?& array [ '1', '2' ]",
3631                "select * from users where user.custom ?& array [ ? ]",
3632            ),
3633        ];
3634        let mut errors = String::new();
3635        for (i, (input, expected)) in cases.iter().enumerate() {
3636            let got = super::obfuscate_sql(input, &config, DbmsKind::Postgresql);
3637            if got != *expected {
3638                let _ = write!(
3639                    errors,
3640                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
3641                );
3642            }
3643        }
3644        assert!(errors.is_empty(), "{errors}");
3645    }
3646
3647    // {'mode': 'normalize_only'}
3648    #[test]
3649    fn test_suite_normalize_only() {
3650        let config = SqlObfuscateConfig {
3651            obfuscation_mode: SqlObfuscationMode::NormalizeOnly,
3652            ..Default::default()
3653        };
3654        let cases: &[(&str, &str)] = &[
3655            // sqllexer_norm_simple
3656            ("SELECT * FROM users WHERE id = 1", "SELECT * FROM users WHERE id = 1"),
3657            // sqllexer_norm_dollar_func
3658            ("SELECT $func$INSERT INTO table VALUES ('a', 1, 2)$func$ FROM users", "SELECT $func$INSERT INTO table VALUES ( 'a', 1, 2 )$func$ FROM users"),
3659            // sqllexer_norm_procedure_on
3660            ("CREATE PROCEDURE TestProc AS BEGIN SELECT * FROM users WHERE id = 1 END", "CREATE PROCEDURE TestProc AS BEGIN SELECT * FROM users WHERE id = 1 END"),
3661            // sqllexer_norm_procedure_off
3662            ("CREATE PROCEDURE TestProc AS BEGIN UPDATE users SET name = 'test' WHERE id = 1 END", "CREATE PROCEDURE TestProc AS BEGIN UPDATE users SET name = 'test' WHERE id = 1 END"),
3663            // sqllexer_norm_null_bool_pos
3664            ("SELECT * FROM users WHERE id = 1 AND address = $1 and id = $2 AND deleted IS NULL AND active is TRUE", "SELECT * FROM users WHERE id = 1 AND address = $1 and id = $2 AND deleted IS NULL AND active is TRUE"),
3665            // sqllexer_norm_cte
3666            ("WITH users AS (SELECT * FROM people) SELECT * FROM users", "WITH users AS ( SELECT * FROM people ) SELECT * FROM users"),
3667        ];
3668        let mut errors = String::new();
3669        for (i, (input, expected)) in cases.iter().enumerate() {
3670            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
3671            if got != *expected {
3672                let _ = write!(
3673                    errors,
3674                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
3675                );
3676            }
3677        }
3678        assert!(errors.is_empty(), "{errors}");
3679    }
3680
3681    // {'mode': 'normalize_only', 'keep_sql_alias': True}
3682    #[test]
3683    fn test_suite_normalize_only_keep_sql_alias() {
3684        let config = SqlObfuscateConfig {
3685            obfuscation_mode: SqlObfuscationMode::NormalizeOnly,
3686            keep_sql_alias: true,
3687            ..Default::default()
3688        };
3689        let cases: &[(&str, &str)] = &[
3690            // sqllexer_norm_comments_alias
3691            ("\n\t\t\t-- comment\n\t\t\t/* comment */\n\t\t\tSELECT id as id, name as n FROM users123 WHERE id in (1,2,3)", "SELECT id as id, name as n FROM users123 WHERE id in ( 1, 2, 3 )"),
3692        ];
3693        let mut errors = String::new();
3694        for (i, (input, expected)) in cases.iter().enumerate() {
3695            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
3696            if got != *expected {
3697                let _ = write!(
3698                    errors,
3699                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
3700                );
3701            }
3702        }
3703        assert!(errors.is_empty(), "{errors}");
3704    }
3705
3706    // {'mode': 'normalize_only', 'remove_space_between_parentheses': True}
3707    #[test]
3708    fn test_suite_normalize_only_remove_space_between_parentheses() {
3709        let config = SqlObfuscateConfig {
3710            obfuscation_mode: SqlObfuscationMode::NormalizeOnly,
3711            remove_space_between_parentheses: true,
3712            ..Default::default()
3713        };
3714        let cases: &[(&str, &str)] = &[
3715            // sqllexer_norm_remove_space_parens
3716            (
3717                "SELECT * FROM users WHERE id = 1 AND (name = 'test' OR name = 'test2')",
3718                "SELECT * FROM users WHERE id = 1 AND (name = 'test' OR name = 'test2')",
3719            ),
3720        ];
3721        let mut errors = String::new();
3722        for (i, (input, expected)) in cases.iter().enumerate() {
3723            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
3724            if got != *expected {
3725                let _ = write!(
3726                    errors,
3727                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
3728                );
3729            }
3730        }
3731        assert!(errors.is_empty(), "{errors}");
3732    }
3733
3734    // {'mode': 'normalize_only', 'keep_trailing_semicolon': True}
3735    #[test]
3736    fn test_suite_normalize_only_keep_trailing_semicolon() {
3737        let config = SqlObfuscateConfig {
3738            obfuscation_mode: SqlObfuscationMode::NormalizeOnly,
3739            keep_trailing_semicolon: true,
3740            ..Default::default()
3741        };
3742        let cases: &[(&str, &str)] = &[
3743            // sqllexer_norm_keep_trailing_semi
3744            (
3745                "SELECT * FROM users WHERE id = 1 AND name = 'test';",
3746                "SELECT * FROM users WHERE id = 1 AND name = 'test';",
3747            ),
3748        ];
3749        let mut errors = String::new();
3750        for (i, (input, expected)) in cases.iter().enumerate() {
3751            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
3752            if got != *expected {
3753                let _ = write!(
3754                    errors,
3755                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
3756                );
3757            }
3758        }
3759        assert!(errors.is_empty(), "{errors}");
3760    }
3761
3762    // {'mode': 'normalize_only', 'keep_identifier_quotation': True}
3763    #[test]
3764    fn test_suite_normalize_only_keep_identifier_quotation() {
3765        let config = SqlObfuscateConfig {
3766            obfuscation_mode: SqlObfuscationMode::NormalizeOnly,
3767            keep_identifier_quotation: true,
3768            ..Default::default()
3769        };
3770        let cases: &[(&str, &str)] = &[
3771            // sqllexer_norm_keep_ident_quot
3772            (
3773                "SELECT * FROM \"users\" WHERE id = 1 AND name = 'test'",
3774                "SELECT * FROM \"users\" WHERE id = 1 AND name = 'test'",
3775            ),
3776        ];
3777        let mut errors = String::new();
3778        for (i, (input, expected)) in cases.iter().enumerate() {
3779            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
3780            if got != *expected {
3781                let _ = write!(
3782                    errors,
3783                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
3784                );
3785            }
3786        }
3787        assert!(errors.is_empty(), "{errors}");
3788    }
3789
3790    // {'mode': 'obfuscate_and_normalize'}
3791    #[test]
3792    fn test_suite_obfuscate_and_normalize() {
3793        let config = SqlObfuscateConfig {
3794            obfuscation_mode: SqlObfuscationMode::ObfuscateAndNormalize,
3795            ..Default::default()
3796        };
3797        let cases: &[(&str, &str)] = &[
3798            // sqllexer_obn_simple
3799            ("SELECT * FROM users WHERE id = 1", "SELECT * FROM users WHERE id = ?"),
3800            // sqllexer_obn_dollar_func_off
3801            ("SELECT $func$INSERT INTO table VALUES ('a', 1, 2)$func$ FROM users", "SELECT ? FROM users"),
3802            // sqllexer_obn_procedure_on
3803            ("CREATE PROCEDURE TestProc AS BEGIN SELECT * FROM users WHERE id = 1 END", "CREATE PROCEDURE TestProc AS BEGIN SELECT * FROM users WHERE id = ? END"),
3804            // sqllexer_obn_procedure_off
3805            ("CREATE PROCEDURE TestProc AS BEGIN UPDATE users SET name = 'test' WHERE id = 1 END", "CREATE PROCEDURE TestProc AS BEGIN UPDATE users SET name = ? WHERE id = ? END"),
3806            // sqllexer_obn_null_bool_pos_param
3807            ("SELECT * FROM users WHERE id = 1 AND address = $1 and id = $2 AND deleted IS NULL AND active is TRUE", "SELECT * FROM users WHERE id = ? AND address = ? and id = ? AND deleted IS ? AND active is ?"),
3808            // sqllexer_obn_create_table
3809            ("CREATE TABLE IF NOT EXISTS users (id INT, name VARCHAR(255))", "CREATE TABLE IF NOT EXISTS users ( id INT, name VARCHAR ( ? ) )"),
3810            // sqllexer_obn_replace_bind_off
3811            ("SELECT * FROM users WHERE id = @P1 AND name = @P2", "SELECT * FROM users WHERE id = @P1 AND name = @P2"),
3812            // sqllexer_obn_pg_only
3813            ("SELECT * FROM ONLY users WHERE id = 1", "SELECT * FROM ONLY users WHERE id = ?"),
3814            // sqllexer_obn_cte
3815            ("WITH users AS (SELECT * FROM people) SELECT * FROM users where id = 1", "WITH users AS ( SELECT * FROM people ) SELECT * FROM users where id = ?"),
3816            // sqllexer_obn_json_path_off
3817            ("SELECT * FROM users WHERE id = 1 AND name->'first' = 'test'", "SELECT * FROM users WHERE id = ? AND name -> ? = ?"),
3818        ];
3819        let mut errors = String::new();
3820        for (i, (input, expected)) in cases.iter().enumerate() {
3821            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
3822            if got != *expected {
3823                let _ = write!(
3824                    errors,
3825                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
3826                );
3827            }
3828        }
3829        assert!(errors.is_empty(), "{errors}");
3830    }
3831
3832    // {'mode': 'obfuscate_and_normalize', 'replace_digits': True}
3833    #[test]
3834    fn test_suite_obfuscate_and_normalize_replace_digits() {
3835        let config = SqlObfuscateConfig {
3836            obfuscation_mode: SqlObfuscationMode::ObfuscateAndNormalize,
3837            replace_digits: true,
3838            ..Default::default()
3839        };
3840        let cases: &[(&str, &str)] = &[
3841            // sqllexer_obn_replace_digits
3842            (
3843                "SELECT * FROM users123 WHERE id = 1",
3844                "SELECT * FROM users? WHERE id = ?",
3845            ),
3846        ];
3847        let mut errors = String::new();
3848        for (i, (input, expected)) in cases.iter().enumerate() {
3849            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
3850            if got != *expected {
3851                let _ = write!(
3852                    errors,
3853                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
3854                );
3855            }
3856        }
3857        assert!(errors.is_empty(), "{errors}");
3858    }
3859
3860    // {'mode': 'obfuscate_and_normalize', 'keep_sql_alias': True}
3861    #[test]
3862    fn test_suite_obfuscate_and_normalize_keep_sql_alias() {
3863        let config = SqlObfuscateConfig {
3864            obfuscation_mode: SqlObfuscationMode::ObfuscateAndNormalize,
3865            keep_sql_alias: true,
3866            ..Default::default()
3867        };
3868        let cases: &[(&str, &str)] = &[
3869            // sqllexer_obn_comments_alias
3870            ("\n\t\t\t-- comment\n\t\t\t/* comment */\n\t\t\tSELECT id as id, name as n FROM users123 WHERE id in (1,2,3)", "SELECT id as id, name as n FROM users123 WHERE id in ( ? )"),
3871        ];
3872        let mut errors = String::new();
3873        for (i, (input, expected)) in cases.iter().enumerate() {
3874            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
3875            if got != *expected {
3876                let _ = write!(
3877                    errors,
3878                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
3879                );
3880            }
3881        }
3882        assert!(errors.is_empty(), "{errors}");
3883    }
3884
3885    // {'mode': 'obfuscate_and_normalize', 'dollar_quoted_func': True}
3886    #[test]
3887    fn test_suite_obfuscate_and_normalize_dollar_quoted_func() {
3888        let config = SqlObfuscateConfig {
3889            obfuscation_mode: SqlObfuscationMode::ObfuscateAndNormalize,
3890            dollar_quoted_func: true,
3891            ..Default::default()
3892        };
3893        let cases: &[(&str, &str)] = &[
3894            // sqllexer_obn_dollar_func_on
3895            (
3896                "SELECT $func$INSERT INTO table VALUES ('a', 1, 2)$func$ FROM users",
3897                "SELECT $func$INSERT INTO table VALUES ( ? )$func$ FROM users",
3898            ),
3899        ];
3900        let mut errors = String::new();
3901        for (i, (input, expected)) in cases.iter().enumerate() {
3902            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
3903            if got != *expected {
3904                let _ = write!(
3905                    errors,
3906                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
3907                );
3908            }
3909        }
3910        assert!(errors.is_empty(), "{errors}");
3911    }
3912
3913    // {'mode': 'obfuscate_and_normalize', 'dollar_quoted_func': True, 'replace_digits': True}
3914    #[test]
3915    fn test_suite_obfuscate_and_normalize_dollar_quoted_func_replace_digits() {
3916        let config = SqlObfuscateConfig {
3917            obfuscation_mode: SqlObfuscationMode::ObfuscateAndNormalize,
3918            dollar_quoted_func: true,
3919            replace_digits: true,
3920            ..Default::default()
3921        };
3922        let cases: &[(&str, &str)] = &[
3923            // sqllexer_obn_dollar_func_digits
3924            (
3925                "SELECT * FROM users123 WHERE id = $tag$1$tag$",
3926                "SELECT * FROM users? WHERE id = ?",
3927            ),
3928        ];
3929        let mut errors = String::new();
3930        for (i, (input, expected)) in cases.iter().enumerate() {
3931            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
3932            if got != *expected {
3933                let _ = write!(
3934                    errors,
3935                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
3936                );
3937            }
3938        }
3939        assert!(errors.is_empty(), "{errors}");
3940    }
3941
3942    // {'mode': 'obfuscate_and_normalize', 'remove_space_between_parentheses': True}
3943    #[test]
3944    fn test_suite_obfuscate_and_normalize_remove_space_between_parentheses() {
3945        let config = SqlObfuscateConfig {
3946            obfuscation_mode: SqlObfuscationMode::ObfuscateAndNormalize,
3947            remove_space_between_parentheses: true,
3948            ..Default::default()
3949        };
3950        let cases: &[(&str, &str)] = &[
3951            // sqllexer_obn_remove_space_parens
3952            (
3953                "SELECT * FROM users WHERE id = 1 AND (name = 'test' OR name = 'test2')",
3954                "SELECT * FROM users WHERE id = ? AND (name = ? OR name = ?)",
3955            ),
3956        ];
3957        let mut errors = String::new();
3958        for (i, (input, expected)) in cases.iter().enumerate() {
3959            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
3960            if got != *expected {
3961                let _ = write!(
3962                    errors,
3963                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
3964                );
3965            }
3966        }
3967        assert!(errors.is_empty(), "{errors}");
3968    }
3969
3970    // {'mode': 'obfuscate_and_normalize', 'keep_null': True}
3971    #[test]
3972    fn test_suite_obfuscate_and_normalize_keep_null() {
3973        let config = SqlObfuscateConfig {
3974            obfuscation_mode: SqlObfuscationMode::ObfuscateAndNormalize,
3975            keep_null: true,
3976            ..Default::default()
3977        };
3978        let cases: &[(&str, &str)] = &[
3979            // sqllexer_obn_keep_null
3980            (
3981                "SELECT * FROM users WHERE id = 1 AND name IS NULL",
3982                "SELECT * FROM users WHERE id = ? AND name IS NULL",
3983            ),
3984        ];
3985        let mut errors = String::new();
3986        for (i, (input, expected)) in cases.iter().enumerate() {
3987            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
3988            if got != *expected {
3989                let _ = write!(
3990                    errors,
3991                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
3992                );
3993            }
3994        }
3995        assert!(errors.is_empty(), "{errors}");
3996    }
3997
3998    // {'mode': 'obfuscate_and_normalize', 'keep_boolean': True}
3999    #[test]
4000    fn test_suite_obfuscate_and_normalize_keep_boolean() {
4001        let config = SqlObfuscateConfig {
4002            obfuscation_mode: SqlObfuscationMode::ObfuscateAndNormalize,
4003            keep_boolean: true,
4004            ..Default::default()
4005        };
4006        let cases: &[(&str, &str)] = &[
4007            // sqllexer_obn_keep_boolean
4008            (
4009                "SELECT * FROM users WHERE id = 1 AND name is TRUE",
4010                "SELECT * FROM users WHERE id = ? AND name is TRUE",
4011            ),
4012        ];
4013        let mut errors = String::new();
4014        for (i, (input, expected)) in cases.iter().enumerate() {
4015            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
4016            if got != *expected {
4017                let _ = write!(
4018                    errors,
4019                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
4020                );
4021            }
4022        }
4023        assert!(errors.is_empty(), "{errors}");
4024    }
4025
4026    // {'mode': 'obfuscate_and_normalize', 'keep_positional_parameter': True}
4027    #[test]
4028    fn test_suite_obfuscate_and_normalize_keep_positional_parameter() {
4029        let config = SqlObfuscateConfig {
4030            obfuscation_mode: SqlObfuscationMode::ObfuscateAndNormalize,
4031            keep_positional_parameter: true,
4032            ..Default::default()
4033        };
4034        let cases: &[(&str, &str)] = &[
4035            // sqllexer_obn_keep_pos_param
4036            (
4037                "SELECT * FROM users WHERE id = 1 AND name = $1 and id = $2",
4038                "SELECT * FROM users WHERE id = ? AND name = $1 and id = $2",
4039            ),
4040        ];
4041        let mut errors = String::new();
4042        for (i, (input, expected)) in cases.iter().enumerate() {
4043            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
4044            if got != *expected {
4045                let _ = write!(
4046                    errors,
4047                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
4048                );
4049            }
4050        }
4051        assert!(errors.is_empty(), "{errors}");
4052    }
4053
4054    // {'mode': 'obfuscate_and_normalize', 'keep_trailing_semicolon': True}
4055    #[test]
4056    fn test_suite_obfuscate_and_normalize_keep_trailing_semicolon() {
4057        let config = SqlObfuscateConfig {
4058            obfuscation_mode: SqlObfuscationMode::ObfuscateAndNormalize,
4059            keep_trailing_semicolon: true,
4060            ..Default::default()
4061        };
4062        let cases: &[(&str, &str)] = &[
4063            // sqllexer_obn_keep_trailing_semi
4064            (
4065                "SELECT * FROM users WHERE id = 1 AND name = 'test';",
4066                "SELECT * FROM users WHERE id = ? AND name = ?;",
4067            ),
4068        ];
4069        let mut errors = String::new();
4070        for (i, (input, expected)) in cases.iter().enumerate() {
4071            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
4072            if got != *expected {
4073                let _ = write!(
4074                    errors,
4075                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
4076                );
4077            }
4078        }
4079        assert!(errors.is_empty(), "{errors}");
4080    }
4081
4082    // {'mode': 'obfuscate_and_normalize', 'keep_identifier_quotation': True}
4083    #[test]
4084    fn test_suite_obfuscate_and_normalize_keep_identifier_quotation() {
4085        let config = SqlObfuscateConfig {
4086            obfuscation_mode: SqlObfuscationMode::ObfuscateAndNormalize,
4087            keep_identifier_quotation: true,
4088            ..Default::default()
4089        };
4090        let cases: &[(&str, &str)] = &[
4091            // sqllexer_obn_keep_ident_quot
4092            (
4093                "SELECT * FROM \"users\" WHERE id = 1 AND name = 'test'",
4094                "SELECT * FROM \"users\" WHERE id = ? AND name = ?",
4095            ),
4096        ];
4097        let mut errors = String::new();
4098        for (i, (input, expected)) in cases.iter().enumerate() {
4099            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
4100            if got != *expected {
4101                let _ = write!(
4102                    errors,
4103                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
4104                );
4105            }
4106        }
4107        assert!(errors.is_empty(), "{errors}");
4108    }
4109
4110    // {'mode': 'obfuscate_and_normalize', 'replace_bind_parameter': True}
4111    #[test]
4112    fn test_suite_obfuscate_and_normalize_replace_bind_parameter() {
4113        let config = SqlObfuscateConfig {
4114            obfuscation_mode: SqlObfuscationMode::ObfuscateAndNormalize,
4115            replace_bind_parameter: true,
4116            ..Default::default()
4117        };
4118        let cases: &[(&str, &str)] = &[
4119            // sqllexer_obn_replace_bind_on
4120            (
4121                "SELECT * FROM users WHERE id = @P1 AND name = @P2",
4122                "SELECT * FROM users WHERE id = ? AND name = ?",
4123            ),
4124        ];
4125        let mut errors = String::new();
4126        for (i, (input, expected)) in cases.iter().enumerate() {
4127            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
4128            if got != *expected {
4129                let _ = write!(
4130                    errors,
4131                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
4132                );
4133            }
4134        }
4135        assert!(errors.is_empty(), "{errors}");
4136    }
4137
4138    // {'mode': 'obfuscate_and_normalize', 'keep_json_path': True}
4139    #[test]
4140    fn test_suite_obfuscate_and_normalize_keep_json_path() {
4141        let config = SqlObfuscateConfig {
4142            obfuscation_mode: SqlObfuscationMode::ObfuscateAndNormalize,
4143            keep_json_path: true,
4144            ..Default::default()
4145        };
4146        let cases: &[(&str, &str)] = &[
4147            // sqllexer_obn_json_path_arrow
4148            (
4149                "SELECT * FROM users WHERE id = 1 AND name->'first' = 'test'",
4150                "SELECT * FROM users WHERE id = ? AND name -> 'first' = ?",
4151            ),
4152            // sqllexer_obn_json_path_double_arrow
4153            (
4154                "SELECT * FROM users WHERE id = 1 AND name->>2 = 'test'",
4155                "SELECT * FROM users WHERE id = ? AND name ->> 2 = ?",
4156            ),
4157        ];
4158        let mut errors = String::new();
4159        for (i, (input, expected)) in cases.iter().enumerate() {
4160            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
4161            if got != *expected {
4162                let _ = write!(
4163                    errors,
4164                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
4165                );
4166            }
4167        }
4168        assert!(errors.is_empty(), "{errors}");
4169    }
4170
4171    // {'mode': 'obfuscate_only'}
4172    #[test]
4173    fn test_suite_obfuscate_only() {
4174        let config = SqlObfuscateConfig {
4175            obfuscation_mode: SqlObfuscationMode::ObfuscateOnly,
4176            ..Default::default()
4177        };
4178        let cases: &[(&str, &str)] = &[
4179            // sqllexer_obf_simple
4180            ("SELECT * FROM users WHERE id = 1", "SELECT * FROM users WHERE id = ?"),
4181            // sqllexer_obf_dollar_question
4182            ("SELECT \"table\".\"field\" FROM \"table\" WHERE \"table\".\"otherfield\" = $? AND \"table\".\"thirdfield\" = $?;", "SELECT \"table\".\"field\" FROM \"table\" WHERE \"table\".\"otherfield\" = $? AND \"table\".\"thirdfield\" = $?;"),
4183            // sqllexer_obf_replace_digits_off
4184            ("SELECT * FROM users123 WHERE id = 1", "SELECT * FROM users123 WHERE id = ?"),
4185            // sqllexer_obf_dollar_quoted_func_off
4186            ("SELECT $func$INSERT INTO table VALUES ('a', 1, 2)$func$ FROM users", "SELECT ? FROM users"),
4187        ];
4188        let mut errors = String::new();
4189        for (i, (input, expected)) in cases.iter().enumerate() {
4190            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
4191            if got != *expected {
4192                let _ = write!(
4193                    errors,
4194                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
4195                );
4196            }
4197        }
4198        assert!(errors.is_empty(), "{errors}");
4199    }
4200
4201    // {'mode': 'obfuscate_only', 'replace_digits': True}
4202    #[test]
4203    fn test_suite_obfuscate_only_replace_digits() {
4204        let config = SqlObfuscateConfig {
4205            obfuscation_mode: SqlObfuscationMode::ObfuscateOnly,
4206            replace_digits: true,
4207            ..Default::default()
4208        };
4209        let cases: &[(&str, &str)] = &[
4210            // sqllexer_obf_replace_digits_on
4211            (
4212                "SELECT * FROM users123 WHERE id = 1",
4213                "SELECT * FROM users? WHERE id = ?",
4214            ),
4215        ];
4216        let mut errors = String::new();
4217        for (i, (input, expected)) in cases.iter().enumerate() {
4218            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
4219            if got != *expected {
4220                let _ = write!(
4221                    errors,
4222                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
4223                );
4224            }
4225        }
4226        assert!(errors.is_empty(), "{errors}");
4227    }
4228
4229    // {'mode': 'obfuscate_only', 'dollar_quoted_func': True}
4230    #[test]
4231    fn test_suite_obfuscate_only_dollar_quoted_func() {
4232        let config = SqlObfuscateConfig {
4233            obfuscation_mode: SqlObfuscationMode::ObfuscateOnly,
4234            dollar_quoted_func: true,
4235            ..Default::default()
4236        };
4237        let cases: &[(&str, &str)] = &[
4238            // sqllexer_obf_dollar_quoted_func_on
4239            (
4240                "SELECT $func$INSERT INTO table VALUES ('a', 1, 2)$func$ FROM users",
4241                "SELECT $func$INSERT INTO table VALUES (?, ?, ?)$func$ FROM users",
4242            ),
4243        ];
4244        let mut errors = String::new();
4245        for (i, (input, expected)) in cases.iter().enumerate() {
4246            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
4247            if got != *expected {
4248                let _ = write!(
4249                    errors,
4250                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
4251                );
4252            }
4253        }
4254        assert!(errors.is_empty(), "{errors}");
4255    }
4256
4257    // {'mode': 'obfuscate_only', 'dollar_quoted_func': True, 'replace_digits': True}
4258    #[test]
4259    fn test_suite_obfuscate_only_dollar_quoted_func_replace_digits() {
4260        let config = SqlObfuscateConfig {
4261            obfuscation_mode: SqlObfuscationMode::ObfuscateOnly,
4262            dollar_quoted_func: true,
4263            replace_digits: true,
4264            ..Default::default()
4265        };
4266        let cases: &[(&str, &str)] = &[
4267            // sqllexer_obf_dollar_func_and_digits
4268            (
4269                "SELECT * FROM users123 WHERE id = $tag$1$tag$",
4270                "SELECT * FROM users? WHERE id = ?",
4271            ),
4272        ];
4273        let mut errors = String::new();
4274        for (i, (input, expected)) in cases.iter().enumerate() {
4275            let got = super::obfuscate_sql(input, &config, DbmsKind::Generic);
4276            if got != *expected {
4277                let _ = write!(
4278                    errors,
4279                    "case {i} ({input:?}):\n  expected {expected:?}\n  got      {got:?}\n"
4280                );
4281            }
4282        }
4283        assert!(errors.is_empty(), "{errors}");
4284    }
4285
4286    // Test that collapse_limit_two_args handles LIMIT case-insensitively.
4287    // In the deprecated mode, the grouping filter is inactive, so
4288    // collapse_limit_two_args is the sole mechanism for both cases.
4289    #[test]
4290    fn test_collapse_limit_case_insensitive() {
4291        #[allow(deprecated)]
4292        let config = SqlObfuscateConfig {
4293            obfuscation_mode: SqlObfuscationMode::Unspecified,
4294            ..Default::default()
4295        };
4296        let got_upper =
4297            super::obfuscate_sql("SELECT * FROM t LIMIT 5, 10", &config, DbmsKind::Generic);
4298        assert_eq!(
4299            got_upper, "SELECT * FROM t LIMIT ?",
4300            "uppercase LIMIT should be collapsed: {got_upper:?}"
4301        );
4302        // eq_ignore_ascii_case fix: lowercase limit must also be collapsed.
4303        let got_lower =
4304            super::obfuscate_sql("SELECT * FROM t limit 5, 10", &config, DbmsKind::Generic);
4305        assert_eq!(
4306            got_lower, "SELECT * FROM t limit ?",
4307            "lowercase limit should also be collapsed: {got_lower:?}"
4308        );
4309    }
4310}