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