Skip to main content

uqa_sql/expr/
scalar_helpers.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Shared string, regex, quoting, and point helpers for scalar built-ins.
8
9use super::{to_f64, value_to_string, Result, SQLError, TemporalValue, Value};
10
11// --------------------------------------------------------------------
12// JSON helpers
13// --------------------------------------------------------------------
14
15pub(super) fn typeof_value(v: &Value) -> String {
16    match v {
17        Value::Null => "null".into(),
18        Value::Void => "void".into(),
19        Value::Bool(_) => "boolean".into(),
20        Value::Int(_) => "integer".into(),
21        Value::Float(_) => "double precision".into(),
22        Value::Decimal(_) => "numeric".into(),
23        Value::Str(_) => "text".into(),
24        Value::FixedChar(_) => "character".into(),
25        Value::Bytes(_) => "bytea".into(),
26        Value::Temporal(value) => match value {
27            TemporalValue::Date { .. } => "date".into(),
28            TemporalValue::Time { .. } => "time without time zone".into(),
29            TemporalValue::TimeTz { .. } => "time with time zone".into(),
30            TemporalValue::Timestamp { .. } => "timestamp without time zone".into(),
31            TemporalValue::TimestampTz { .. } => "timestamp with time zone".into(),
32            TemporalValue::Interval { .. } => "interval".into(),
33        },
34        Value::Json(_) => "json".into(),
35        Value::JsonB(_) => "jsonb".into(),
36        Value::Array(_) => "array".into(),
37        Value::List(_) => "array".into(),
38        Value::Row(_) | Value::Record(_) => "record".into(),
39        Value::Map(_) => "jsonb".into(),
40    }
41}
42
43pub(super) fn point_xy(v: &Value) -> Result<(f64, f64)> {
44    match v {
45        Value::List(items) if items.len() == 2 => Ok((to_f64(&items[0])?, to_f64(&items[1])?)),
46        Value::Str(s) | Value::FixedChar(s) => {
47            let cleaned = s.trim_matches(|c: char| c == '(' || c == ')' || c == '[' || c == ']');
48            let parts: Vec<&str> = cleaned.split(',').map(str::trim).collect();
49            if parts.len() != 2 {
50                return Err(SQLError::TypeMismatch(format!("point: cannot parse {s:?}")));
51            }
52            let x: f64 = parts[0]
53                .parse()
54                .map_err(|e| SQLError::TypeMismatch(format!("point.x: {e}")))?;
55            let y: f64 = parts[1]
56                .parse()
57                .map_err(|e| SQLError::TypeMismatch(format!("point.y: {e}")))?;
58            Ok((x, y))
59        }
60        other => Err(SQLError::TypeMismatch(format!(
61            "point: not coercible {other:?}"
62        ))),
63    }
64}
65
66/// A LIKE/ILIKE pattern compiled once for repeated evaluation.
67///
68/// ASCII values use a byte matcher without per-row allocation. Unicode keeps
69/// SQL's character-oriented `_` semantics and the existing lowercase rules.
70pub struct CompiledLikePattern {
71    case_insensitive: bool,
72    pattern_chars: Vec<LikePatternToken<char>>,
73    pattern_ascii: Option<Vec<LikePatternToken<u8>>>,
74}
75
76#[derive(Clone, Copy, PartialEq, Eq)]
77enum LikePatternToken<T> {
78    Literal(T),
79    AnyOne,
80    AnyMany,
81    DanglingEscape,
82}
83
84impl CompiledLikePattern {
85    #[must_use]
86    pub fn new(pattern: &str, case_insensitive: bool) -> Self {
87        Self::with_escape(pattern, case_insensitive, None)
88            .expect("the default LIKE escape is exactly one character")
89    }
90
91    #[must_use]
92    pub fn from_value(pattern: &Value, case_insensitive: bool) -> Self {
93        Self::new(&value_to_string(pattern), case_insensitive)
94    }
95
96    /// Compile a pattern with `PostgreSQL`'s optional `ESCAPE` clause.
97    /// `None` selects the default backslash, while `Some("")` disables escaping.
98    pub fn with_escape(
99        pattern: &str,
100        case_insensitive: bool,
101        escape: Option<&str>,
102    ) -> Result<Self> {
103        let escape = like_escape_character(escape)?;
104        let pattern_chars = compile_like_pattern(pattern, case_insensitive, escape);
105        let pattern_ascii = pattern_chars
106            .iter()
107            .map(|token| match token {
108                LikePatternToken::Literal(character) if character.is_ascii() => {
109                    Some(LikePatternToken::Literal(*character as u8))
110                }
111                LikePatternToken::Literal(_) => None,
112                LikePatternToken::AnyOne => Some(LikePatternToken::AnyOne),
113                LikePatternToken::AnyMany => Some(LikePatternToken::AnyMany),
114                LikePatternToken::DanglingEscape => Some(LikePatternToken::DanglingEscape),
115            })
116            .collect::<Option<Vec<_>>>();
117        Ok(Self {
118            case_insensitive,
119            pattern_chars,
120            pattern_ascii,
121        })
122    }
123
124    #[must_use]
125    pub fn is_match(&self, haystack: &str) -> bool {
126        self.try_is_match(haystack).unwrap_or(false)
127    }
128
129    /// Match while preserving `PostgreSQL` errors such as a dangling escape.
130    pub fn try_is_match(&self, haystack: &str) -> Result<bool> {
131        if self.case_insensitive {
132            let normalized = haystack.to_lowercase();
133            if let Some(pattern) = self
134                .pattern_ascii
135                .as_deref()
136                .filter(|_| normalized.is_ascii())
137            {
138                return wildcard_match(normalized.as_bytes(), pattern);
139            }
140            let haystack = normalized.chars().collect::<Vec<_>>();
141            return wildcard_match(&haystack, &self.pattern_chars);
142        }
143        if let Some(pattern) = self
144            .pattern_ascii
145            .as_deref()
146            .filter(|_| haystack.is_ascii())
147        {
148            return wildcard_match(haystack.as_bytes(), pattern);
149        }
150        let haystack = haystack.chars().collect::<Vec<_>>();
151        wildcard_match(&haystack, &self.pattern_chars)
152    }
153
154    #[must_use]
155    pub fn matches_value(&self, haystack: &Value) -> bool {
156        self.try_matches_value(haystack).unwrap_or(false)
157    }
158
159    /// Match a value while preserving `PostgreSQL` pattern errors.
160    pub fn try_matches_value(&self, haystack: &Value) -> Result<bool> {
161        match haystack {
162            Value::Str(text) => self.try_is_match(text),
163            Value::FixedChar(text) => self.try_is_match(text.trim_end_matches(' ')),
164            Value::Null => self.try_is_match(""),
165            other => self.try_is_match(&value_to_string(other)),
166        }
167    }
168}
169
170fn like_escape_character(escape: Option<&str>) -> Result<Option<char>> {
171    let Some(escape) = escape else {
172        return Ok(Some('\\'));
173    };
174    let mut characters = escape.chars();
175    let first = characters.next();
176    if characters.next().is_some() {
177        return Err(SQLError::Routine {
178            sqlstate: "22025".into(),
179            message: "invalid escape string".into(),
180        });
181    }
182    Ok(first)
183}
184
185fn compile_like_pattern(
186    pattern: &str,
187    case_insensitive: bool,
188    escape: Option<char>,
189) -> Vec<LikePatternToken<char>> {
190    let mut output = Vec::with_capacity(pattern.chars().count());
191    let mut characters = pattern.chars();
192    while let Some(character) = characters.next() {
193        if escape == Some(character) {
194            let Some(literal) = characters.next() else {
195                output.push(LikePatternToken::DanglingEscape);
196                break;
197            };
198            push_like_literal(&mut output, literal, case_insensitive);
199            continue;
200        }
201        match character {
202            '%' => output.push(LikePatternToken::AnyMany),
203            '_' => output.push(LikePatternToken::AnyOne),
204            literal => push_like_literal(&mut output, literal, case_insensitive),
205        }
206    }
207    output
208}
209
210fn push_like_literal(
211    output: &mut Vec<LikePatternToken<char>>,
212    literal: char,
213    case_insensitive: bool,
214) {
215    if case_insensitive {
216        output.extend(literal.to_lowercase().map(LikePatternToken::Literal));
217    } else {
218        output.push(LikePatternToken::Literal(literal));
219    }
220}
221
222fn wildcard_match<T: Copy + Eq>(haystack: &[T], pattern: &[LikePatternToken<T>]) -> Result<bool> {
223    let mut haystack_index = 0;
224    let mut pattern_index = 0;
225    let mut star: Option<(usize, usize)> = None;
226    while haystack_index < haystack.len() {
227        match pattern.get(pattern_index) {
228            Some(LikePatternToken::Literal(literal)) if *literal == haystack[haystack_index] => {
229                haystack_index += 1;
230                pattern_index += 1;
231            }
232            Some(LikePatternToken::AnyOne) => {
233                haystack_index += 1;
234                pattern_index += 1;
235            }
236            Some(LikePatternToken::AnyMany) => {
237                star = Some((pattern_index, haystack_index));
238                pattern_index += 1;
239            }
240            Some(LikePatternToken::DanglingEscape) => {
241                return Err(SQLError::Routine {
242                    sqlstate: "22025".into(),
243                    message: "LIKE pattern must not end with escape character".into(),
244                });
245            }
246            _ => {
247                if let Some((star_pattern, star_haystack)) = star {
248                    pattern_index = star_pattern + 1;
249                    haystack_index = star_haystack + 1;
250                    star = Some((star_pattern, star_haystack + 1));
251                } else {
252                    return Ok(false);
253                }
254            }
255        }
256    }
257    while matches!(pattern.get(pattern_index), Some(LikePatternToken::AnyMany)) {
258        pattern_index += 1;
259    }
260    Ok(pattern_index == pattern.len())
261}
262
263/// `trim` / `ltrim` / `rtrim` / `btrim` with the optional
264/// character-SET second argument (defaults to whitespace).
265pub(super) fn trim_chars(args: &[Value], start: bool, end: bool) -> Result<Value> {
266    if args.is_empty() || args.len() > 2 {
267        return Err(SQLError::TypeMismatch("trim takes 1-2 args".into()));
268    }
269    if args.iter().any(|arg| matches!(arg, Value::Null)) {
270        return Ok(Value::Null);
271    }
272    let s = value_to_string(&args[0]);
273    let out = match args.get(1) {
274        None => match (start, end) {
275            (true, true) => s.trim(),
276            (true, false) => s.trim_start(),
277            (false, true) => s.trim_end(),
278            (false, false) => s.as_str(),
279        }
280        .to_string(),
281        Some(set) => {
282            let set: Vec<char> = value_to_string(set).chars().collect();
283            let matches_set = |c: char| set.contains(&c);
284            let mut out = s.as_str();
285            if start {
286                out = out.trim_start_matches(matches_set);
287            }
288            if end {
289                out = out.trim_end_matches(matches_set);
290            }
291            out.to_string()
292        }
293    };
294    Ok(Value::Str(out))
295}
296
297/// Compile a regex with `PostgreSQL` match-flag behavior.
298pub(super) fn compile_pg_regex(
299    pattern: &str,
300    flags: &str,
301    global_allowed: bool,
302) -> Result<regex::Regex> {
303    #[derive(Clone, Copy)]
304    enum Syntax {
305        Advanced,
306        Basic,
307        Quoted,
308    }
309
310    let mut case_insensitive = false;
311    let mut multi_line = false;
312    let mut dot_matches_new_line = true;
313    let mut expanded = false;
314    let mut syntax = Syntax::Advanced;
315    for flag in flags.chars() {
316        match flag {
317            'g' if global_allowed => {}
318            // PostgreSQL 18 clears the composite `REG_ADVANCED` mask after
319            // setting `REG_EXTENDED`, which leaves both `b` and `e` using
320            // BRE behavior. Match the server's observable behavior exactly.
321            'b' | 'e' => syntax = Syntax::Basic,
322            'c' => case_insensitive = false,
323            'i' => case_insensitive = true,
324            'm' | 'n' => {
325                multi_line = true;
326                dot_matches_new_line = false;
327            }
328            'p' => {
329                multi_line = false;
330                dot_matches_new_line = false;
331            }
332            'q' => syntax = Syntax::Quoted,
333            's' => {
334                multi_line = false;
335                dot_matches_new_line = true;
336            }
337            't' => expanded = false,
338            'w' => {
339                multi_line = true;
340                dot_matches_new_line = true;
341            }
342            'x' => expanded = true,
343            invalid => {
344                return Err(SQLError::Routine {
345                    sqlstate: "22023".into(),
346                    message: format!("invalid regular expression option: \"{invalid}\""),
347                });
348            }
349        }
350    }
351    if matches!(syntax, Syntax::Quoted) && (expanded || multi_line || !dot_matches_new_line) {
352        return Err(SQLError::Routine {
353            sqlstate: "2201B".into(),
354            message: "invalid regular expression: invalid argument to regex function".into(),
355        });
356    }
357    let pattern = if expanded {
358        expand_postgres_regex(pattern)
359    } else {
360        pattern.to_string()
361    };
362    let pattern = match syntax {
363        Syntax::Advanced => pattern,
364        Syntax::Basic => postgres_basic_regex(&pattern),
365        Syntax::Quoted => regex::escape(&pattern),
366    };
367    let pattern = postgres_character_class_regex(&pattern, !dot_matches_new_line);
368    let mut builder = regex::RegexBuilder::new(&pattern);
369    builder
370        .case_insensitive(case_insensitive)
371        .multi_line(multi_line)
372        .dot_matches_new_line(dot_matches_new_line);
373    builder.build().map_err(|error| SQLError::Routine {
374        sqlstate: "2201B".into(),
375        message: format!("invalid regular expression: {error}"),
376    })
377}
378
379fn postgres_character_class_regex(pattern: &str, exclude_newline: bool) -> String {
380    let characters = pattern.chars().collect::<Vec<_>>();
381    let mut output = String::with_capacity(pattern.len());
382    let mut position = 0usize;
383    let mut in_bracket = false;
384    let mut bracket_can_close = false;
385    while let Some(&character) = characters.get(position) {
386        position += 1;
387        if character == '\\' {
388            output.push(character);
389            if let Some(&escaped) = characters.get(position) {
390                position += 1;
391                output.push(escaped);
392                if in_bracket {
393                    bracket_can_close = true;
394                }
395            }
396            continue;
397        }
398        if !in_bracket {
399            output.push(character);
400            if character == '[' {
401                in_bracket = true;
402                bracket_can_close = false;
403                if characters.get(position) == Some(&'^') {
404                    position += 1;
405                    output.push('^');
406                    if characters.get(position) == Some(&']') {
407                        position += 1;
408                        output.push(']');
409                        bracket_can_close = true;
410                    }
411                    if exclude_newline {
412                        output.push_str("\\n");
413                        if characters.get(position) == Some(&'-') {
414                            position += 1;
415                            output.push_str("\\-");
416                            bracket_can_close = true;
417                        }
418                    }
419                }
420            }
421            continue;
422        }
423        if character == '[' && matches!(characters.get(position), Some('.' | ':' | '=')) {
424            let delimiter = characters[position];
425            output.push(character);
426            output.push(delimiter);
427            position += 1;
428            while let Some(&nested) = characters.get(position) {
429                position += 1;
430                output.push(nested);
431                if nested == delimiter && characters.get(position) == Some(&']') {
432                    output.push(']');
433                    position += 1;
434                    break;
435                }
436            }
437            bracket_can_close = true;
438            continue;
439        }
440        if character == '[' {
441            output.push_str("\\[");
442            bracket_can_close = true;
443            continue;
444        }
445        output.push(character);
446        if character == ']' && bracket_can_close {
447            in_bracket = false;
448        } else if character != '^' || bracket_can_close {
449            bracket_can_close = true;
450        }
451    }
452    output
453}
454
455fn expand_postgres_regex(pattern: &str) -> String {
456    let mut output = String::with_capacity(pattern.len());
457    let mut characters = pattern.chars().peekable();
458    let mut in_bracket = false;
459    let mut bracket_can_close = false;
460    while let Some(character) = characters.next() {
461        if character == '\\' {
462            output.push(character);
463            if let Some(escaped) = characters.next() {
464                output.push(escaped);
465                if in_bracket {
466                    bracket_can_close = true;
467                }
468            }
469            continue;
470        }
471        if in_bracket {
472            if character == '[' {
473                if let Some(delimiter @ ('.' | ':' | '=')) = characters.peek().copied() {
474                    output.push(character);
475                    output.push(delimiter);
476                    characters.next();
477                    while let Some(nested) = characters.next() {
478                        output.push(nested);
479                        if nested == delimiter && characters.peek() == Some(&']') {
480                            output.push(']');
481                            characters.next();
482                            break;
483                        }
484                    }
485                    bracket_can_close = true;
486                    continue;
487                }
488            }
489            output.push(character);
490            if character == ']' && bracket_can_close {
491                in_bracket = false;
492            } else if character != '^' || bracket_can_close {
493                bracket_can_close = true;
494            }
495            continue;
496        }
497        match character {
498            '[' => {
499                in_bracket = true;
500                bracket_can_close = false;
501                output.push(character);
502            }
503            '#' => {
504                for comment in characters.by_ref() {
505                    if comment == '\n' {
506                        break;
507                    }
508                }
509            }
510            whitespace if postgres_expanded_regex_whitespace(whitespace) => {}
511            other => output.push(other),
512        }
513    }
514    output
515}
516
517fn postgres_expanded_regex_whitespace(character: char) -> bool {
518    matches!(
519        character,
520        '\u{0009}'..='\u{000D}'
521            | '\u{0020}'
522            | '\u{1680}'
523            | '\u{2000}'..='\u{2006}'
524            | '\u{2008}'..='\u{200A}'
525            | '\u{2028}'..='\u{2029}'
526            | '\u{205F}'
527            | '\u{3000}'
528    )
529}
530
531fn postgres_basic_regex(pattern: &str) -> String {
532    let mut output = String::with_capacity(pattern.len());
533    let characters = pattern.chars().collect::<Vec<_>>();
534    let mut position = 0usize;
535    let mut in_bracket = false;
536    let mut bracket_can_close = false;
537    let mut at_subexpression_start = true;
538    while let Some(&character) = characters.get(position) {
539        position += 1;
540        if in_bracket {
541            if character == '\\' {
542                output.push_str(r"\\");
543                bracket_can_close = true;
544                continue;
545            }
546            output.push(character);
547            if character == ']' && bracket_can_close {
548                in_bracket = false;
549                at_subexpression_start = false;
550            } else if character != '^' || bracket_can_close {
551                bracket_can_close = true;
552            }
553            continue;
554        }
555        if character == '\\' {
556            match characters.get(position).copied() {
557                Some('(') => {
558                    position += 1;
559                    output.push('(');
560                    at_subexpression_start = true;
561                }
562                Some(')') => {
563                    position += 1;
564                    output.push(')');
565                    at_subexpression_start = false;
566                }
567                Some(bound @ ('{' | '}')) => {
568                    position += 1;
569                    output.push(bound);
570                }
571                Some(escaped) if escaped.is_ascii_alphabetic() => {
572                    position += 1;
573                    output.push(escaped);
574                    at_subexpression_start = false;
575                }
576                Some(escaped) => {
577                    position += 1;
578                    output.push('\\');
579                    output.push(escaped);
580                    at_subexpression_start = false;
581                }
582                None => output.push('\\'),
583            }
584            continue;
585        }
586        match character {
587            '[' => {
588                in_bracket = true;
589                bracket_can_close = false;
590                output.push(character);
591            }
592            '^' if at_subexpression_start => output.push(character),
593            '^' => {
594                output.push_str(r"\^");
595                at_subexpression_start = false;
596            }
597            '$' => {
598                let closes_subexpression = matches!(
599                    (characters.get(position), characters.get(position + 1)),
600                    (Some('\\'), Some(')'))
601                );
602                if position == characters.len() || closes_subexpression {
603                    output.push(character);
604                } else {
605                    output.push_str(r"\$");
606                    at_subexpression_start = false;
607                }
608            }
609            '*' if at_subexpression_start => {
610                output.push_str(r"\*");
611                at_subexpression_start = false;
612            }
613            literal @ ('+' | '?' | '(' | ')' | '{' | '}' | '|') => {
614                output.push('\\');
615                output.push(literal);
616                at_subexpression_start = false;
617            }
618            other => {
619                output.push(other);
620                at_subexpression_start = false;
621            }
622        }
623    }
624    output
625}
626
627/// Reserved / type / column-name keywords `PostgreSQL`'s
628/// `quote_ident` quotes even when the identifier is otherwise safe.
629#[expect(
630    clippy::too_many_lines,
631    reason = "builtin dispatch preserves arity, NULL, and error precedence"
632)]
633pub(super) fn is_quoted_keyword(word: &str) -> bool {
634    const KEYWORDS: &[&str] = &[
635        "all",
636        "analyse",
637        "analyze",
638        "and",
639        "any",
640        "array",
641        "as",
642        "asc",
643        "asymmetric",
644        "authorization",
645        "between",
646        "bigint",
647        "binary",
648        "bit",
649        "boolean",
650        "both",
651        "case",
652        "cast",
653        "char",
654        "character",
655        "check",
656        "coalesce",
657        "collate",
658        "collation",
659        "column",
660        "concurrently",
661        "constraint",
662        "create",
663        "cross",
664        "current_catalog",
665        "current_date",
666        "current_role",
667        "current_schema",
668        "current_time",
669        "current_timestamp",
670        "current_user",
671        "dec",
672        "decimal",
673        "default",
674        "deferrable",
675        "desc",
676        "distinct",
677        "do",
678        "else",
679        "end",
680        "except",
681        "exists",
682        "extract",
683        "false",
684        "fetch",
685        "float",
686        "for",
687        "foreign",
688        "freeze",
689        "from",
690        "full",
691        "grant",
692        "greatest",
693        "group",
694        "grouping",
695        "having",
696        "ilike",
697        "in",
698        "initially",
699        "inner",
700        "inout",
701        "int",
702        "integer",
703        "intersect",
704        "interval",
705        "into",
706        "is",
707        "isnull",
708        "join",
709        "json",
710        "json_array",
711        "json_arrayagg",
712        "json_exists",
713        "json_object",
714        "json_objectagg",
715        "json_query",
716        "json_scalar",
717        "json_serialize",
718        "json_table",
719        "json_value",
720        "lateral",
721        "leading",
722        "least",
723        "left",
724        "like",
725        "limit",
726        "localtime",
727        "localtimestamp",
728        "merge_action",
729        "national",
730        "natural",
731        "nchar",
732        "none",
733        "normalize",
734        "not",
735        "notnull",
736        "null",
737        "nullif",
738        "numeric",
739        "offset",
740        "on",
741        "only",
742        "or",
743        "order",
744        "out",
745        "outer",
746        "overlaps",
747        "overlay",
748        "placing",
749        "position",
750        "precision",
751        "primary",
752        "real",
753        "references",
754        "returning",
755        "right",
756        "row",
757        "select",
758        "session_user",
759        "setof",
760        "similar",
761        "smallint",
762        "some",
763        "substring",
764        "symmetric",
765        "system_user",
766        "table",
767        "tablesample",
768        "then",
769        "time",
770        "timestamp",
771        "to",
772        "trailing",
773        "treat",
774        "trim",
775        "true",
776        "union",
777        "unique",
778        "user",
779        "using",
780        "values",
781        "varchar",
782        "variadic",
783        "verbose",
784        "when",
785        "where",
786        "window",
787        "with",
788        "xmlattributes",
789        "xmlconcat",
790        "xmlelement",
791        "xmlexists",
792        "xmlforest",
793        "xmlnamespaces",
794        "xmlparse",
795        "xmlpi",
796        "xmlroot",
797        "xmlserialize",
798        "xmltable",
799    ];
800    KEYWORDS.binary_search(&word).is_ok()
801}
802
803/// `quote_ident`: double-quote unless the identifier is a safe
804/// lower-case name that is not a keyword.
805pub fn quote_ident(ident: &str) -> String {
806    let safe = !ident.is_empty()
807        && ident.chars().enumerate().all(|(i, c)| {
808            c.is_ascii_lowercase() || c == '_' || (i > 0 && (c.is_ascii_digit() || c == '$'))
809        });
810    if safe && !is_quoted_keyword(ident) {
811        return ident.to_string();
812    }
813    format!("\"{}\"", ident.replace('"', "\"\""))
814}
815
816/// `quote_literal`: single-quote with doubled quotes; backslashes
817/// switch to the `E'...'` form with doubled backslashes.
818pub(super) fn quote_literal(text: &str) -> String {
819    let escaped = text.replace('\'', "''");
820    if escaped.contains('\\') {
821        format!("E'{}'", escaped.replace('\\', "\\\\"))
822    } else {
823        format!("'{escaped}'")
824    }
825}
826
827/// Translate a SQL `SIMILAR TO` pattern into an anchored PostgreSQL-style regex.
828/// `None` selects the default backslash escape and `Some("")` disables escaping.
829pub(super) fn similar_to_regex(pattern: &str, escape: Option<&str>) -> Result<String> {
830    let escape = like_escape_character(escape)?;
831    let mut out = String::with_capacity(pattern.len() + 8);
832    out.push_str("^(?:");
833    let mut after_escape = false;
834    let mut quote_count = 0;
835    let mut bracket_depth = 0usize;
836    let mut bracket_position = 0usize;
837    for character in pattern.chars() {
838        if after_escape {
839            if character == '"' && bracket_depth == 0 {
840                match quote_count {
841                    0 => out.push_str("){1,1}?("),
842                    1 => out.push_str("){1,1}(?:"),
843                    _ => {
844                        return Err(SQLError::Routine {
845                            sqlstate: "2200C".into(),
846                            message: "SQL regular expression may not contain more than two escape-double-quote separators".into(),
847                        });
848                    }
849                }
850                quote_count += 1;
851            } else {
852                push_similar_escaped(&mut out, character);
853                bracket_position = 3;
854            }
855            after_escape = false;
856            continue;
857        }
858        if escape == Some(character) {
859            after_escape = true;
860            continue;
861        }
862        if bracket_depth > 0 {
863            if character == '\\' && escape != Some('\\') {
864                out.push('\\');
865            }
866            out.push(character);
867            if character == ']' && bracket_position > 2 {
868                bracket_depth -= 1;
869            } else if character == '[' {
870                bracket_depth += 1;
871                bracket_position = 3;
872            } else if character == '^' {
873                bracket_position += 1;
874            } else {
875                bracket_position = 3;
876            }
877            continue;
878        }
879        match character {
880            '%' => out.push_str(".*"),
881            '_' => out.push('.'),
882            '[' => {
883                bracket_depth = 1;
884                bracket_position = 1;
885                out.push('[');
886            }
887            '(' => out.push_str("(?:"),
888            '\\' | '.' | '^' | '$' => {
889                out.push('\\');
890                out.push(character);
891            }
892            other => out.push(other),
893        }
894    }
895    out.push_str(")$");
896    Ok(out)
897}
898
899fn push_similar_escaped(output: &mut String, character: char) {
900    match character {
901        'b' => {
902            output.push_str(r"\x08");
903            return;
904        }
905        'B' => {
906            output.push_str(r"\\");
907            return;
908        }
909        _ => {}
910    }
911    if character.is_ascii_alphanumeric()
912        || matches!(
913            character,
914            '\\' | '.'
915                | '^'
916                | '$'
917                | '|'
918                | '?'
919                | '*'
920                | '+'
921                | '('
922                | ')'
923                | '{'
924                | '}'
925                | '['
926                | ']'
927                | '-'
928        )
929    {
930        output.push('\\');
931    }
932    output.push(character);
933}
934
935#[cfg(test)]
936mod regex_tests;