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