Skip to main content

mz_expr/scalar/
like_pattern.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10use std::mem;
11use std::str::FromStr;
12
13use derivative::Derivative;
14use mz_ore::fmt::FormatBuffer;
15use mz_repr::adt::regex::{Regex, RegexCompilationError};
16use serde::{Deserialize, Serialize};
17
18use crate::scalar::EvalError;
19
20/// The number of subpatterns after which using regexes would be more efficient.
21const MAX_SUBPATTERNS: usize = 5;
22
23/// The escape character to use by default in LIKE patterns.
24const DEFAULT_ESCAPE: char = '\\';
25const DOUBLED_ESCAPE: &str = "\\\\";
26
27/// Specifies escape behavior for the LIKE pattern.
28#[derive(Clone, Copy, Debug)]
29pub enum EscapeBehavior {
30    /// No escape character.
31    Disabled,
32    /// Use a custom escape character.
33    Char(char),
34}
35
36impl Default for EscapeBehavior {
37    fn default() -> EscapeBehavior {
38        EscapeBehavior::Char(DEFAULT_ESCAPE)
39    }
40}
41
42impl FromStr for EscapeBehavior {
43    type Err = EvalError;
44
45    fn from_str(s: &str) -> Result<EscapeBehavior, EvalError> {
46        let mut chars = s.chars();
47        match chars.next() {
48            None => Ok(EscapeBehavior::Disabled),
49            Some(c) => match chars.next() {
50                None => Ok(EscapeBehavior::Char(c)),
51                Some(_) => Err(EvalError::LikeEscapeTooLong),
52            },
53        }
54    }
55}
56
57/// Converts a pattern string that uses a custom escape character to one that uses the default.
58pub fn normalize_pattern(pattern: &str, escape: EscapeBehavior) -> Result<String, EvalError> {
59    match escape {
60        EscapeBehavior::Disabled => Ok(pattern.replace(DEFAULT_ESCAPE, DOUBLED_ESCAPE)),
61        EscapeBehavior::Char(DEFAULT_ESCAPE) => Ok(pattern.into()),
62        EscapeBehavior::Char(custom_escape_char) => {
63            let mut p = String::with_capacity(2 * pattern.len());
64            let mut cs = pattern.chars();
65            while let Some(c) = cs.next() {
66                if c == custom_escape_char {
67                    match cs.next() {
68                        Some(c2) => {
69                            p.push(DEFAULT_ESCAPE);
70                            p.push(c2);
71                        }
72                        None => return Err(EvalError::UnterminatedLikeEscapeSequence),
73                    }
74                } else if c == DEFAULT_ESCAPE {
75                    p.push_str(DOUBLED_ESCAPE);
76                } else {
77                    p.push(c);
78                }
79            }
80            p.shrink_to_fit();
81            Ok(p)
82        }
83    }
84}
85
86// This implementation supports a couple of different methods of matching
87// text against a SQL LIKE or ILIKE pattern.
88//
89// The most general approach is to convert the LIKE pattern into a
90// regular expression and use the well-tested Regex library to perform the
91// match. This works well with complex patterns and case-insensitive matches
92// that are hard to get right.
93//
94// That said, regular expressions aren't that efficient. For most patterns
95// we can do better using built-in string matching.
96
97pub use matcher::Matcher;
98use matcher::MatcherImpl;
99
100// This lint interacts poorly with `derivative` here; we are confident it generates
101// compatible `PartialOrd` and `Ord` impls. Unfortunately it also requires we introduce
102// this module to allow it.
103#[allow(clippy::non_canonical_partial_ord_impl)]
104mod matcher {
105    use super::*;
106
107    /// An object that can test whether a string matches a LIKE or ILIKE pattern.
108    #[derive(Debug, Clone, Deserialize, Serialize, Derivative)]
109    #[derivative(Eq, PartialEq, Ord, PartialOrd, Hash)]
110    pub struct Matcher {
111        pub pattern: String,
112        pub case_insensitive: bool,
113        #[derivative(
114            PartialEq = "ignore",
115            Hash = "ignore",
116            Ord = "ignore",
117            PartialOrd = "ignore"
118        )]
119        pub(super) matcher_impl: MatcherImpl,
120    }
121
122    impl Matcher {
123        pub fn is_match(&self, text: &str) -> bool {
124            match &self.matcher_impl {
125                MatcherImpl::String(subpatterns) => is_match_subpatterns(subpatterns, text),
126                MatcherImpl::Regex(r) => r.is_match(text),
127            }
128        }
129    }
130
131    #[derive(Debug, Clone, Deserialize, Serialize)]
132    pub(super) enum MatcherImpl {
133        String(Vec<Subpattern>),
134        Regex(Regex),
135    }
136}
137
138/// Builds a Matcher that matches a SQL LIKE pattern.
139pub fn compile(pattern: &str, case_insensitive: bool) -> Result<Matcher, EvalError> {
140    // We would like to have a consistent, documented limit to the size of
141    // supported LIKE patterns. The real limiting factor is the number of states
142    // that can be handled by the Regex library. In testing, I was able to
143    // create an adversarial pattern "%a%b%c%d%e..." that started failing around
144    // 9 KiB, so we chose 8 KiB as the limit. This is consistent with limits
145    // set by other databases, like SQL Server.
146    // On the other hand, PostgreSQL does not have a documented limit.
147    if pattern.len() > 8 << 10 {
148        return Err(EvalError::LikePatternTooLong);
149    }
150    let subpatterns = build_subpatterns(pattern)?;
151    // `is_match_subpatterns` resolves each `%` (a `many` subpattern) by searching
152    // for the following suffix and backtracking over every candidate position. A
153    // single `%` is near-linear, but with two or more the backtracking nests and
154    // the cost becomes super-linear in the text length — an adversarial pattern
155    // like `%a%a%a` against a long run of `a`s takes time proportional to
156    // `len(text)^(number of %)`, which can stall a worker for many minutes. The
157    // regex engine matches the same patterns in linear time with no backtracking,
158    // so fall back to it whenever more than one backtracking `%` is present (and
159    // for the existing case-insensitive / too-many-subpatterns reasons).
160    //
161    // A `%` with an *empty* suffix short-circuits in `is_match_subpatterns`
162    // without any `rfind`/backtracking, and (per `build_subpatterns`) such a `%`
163    // can only ever be the trailing subpattern. Only `%` subpatterns with a
164    // non-empty suffix nest the backtracking loops, so we count just those. This
165    // keeps the common "contains" pattern `%foo%` — which decomposes into one
166    // non-empty-suffix `%` plus a trailing empty-suffix `%` — on the fast string
167    // matcher, where a single `rfind` is far cheaper than a forward regex scan.
168    let backtracking_manys = subpatterns
169        .iter()
170        .filter(|s| s.many && !s.suffix.is_empty())
171        .count();
172    let use_regex =
173        case_insensitive || subpatterns.len() > MAX_SUBPATTERNS || backtracking_manys > 1;
174    let matcher_impl = match use_regex {
175        false => MatcherImpl::String(subpatterns),
176        true => MatcherImpl::Regex(build_regex(&subpatterns, case_insensitive)?),
177    };
178    Ok(Matcher {
179        pattern: pattern.into(),
180        case_insensitive,
181        matcher_impl,
182    })
183}
184
185// The algorithm below is based on the observation that any LIKE pattern can be
186// decomposed into multiple parts:
187//     <PATTERN> := <SUB-PATTERN> (<SUB-PATTERN> ...)
188//     <SUB-PATTERN> := <WILDCARDS> <SUFFIX>
189//
190// The sub-patterns start with zero or more wildcard characters, eventually
191// followed by (non-wildcard) literal characters. The last sub-pattern may
192// have an empty SUFFIX.
193//
194// Example: the PATTERN "n__dl%" can be broken into the following parts:
195//   1. SUB-PATTERN = <WILDCARDS ""> <SUFFIX "n">
196//   2. SUB-PATTERN = <WILDCARDS "__"> <SUFFIX "dl">
197//   3. SUB-PATTERN = <WILDCARDS "%"> <SUFFIX "">
198//
199// The WILDCARDS can be any combination of '_', which matches exactly 1 char,
200// and '%' which matches zero or more chars. These wildcards can be simplified
201// down to the (min, max) of characters they might consume:
202//     ""  = (0, 0)    // doesn't consume any characters
203//     "_" = (1, 1)    // consumes exactly one
204//     "%" = (0, many) // zero or more
205// These are additive, so:
206//     "_%"    = (1, many)
207//     "__%__" = (4, many)
208//     "%%%_"  = (1, many)
209
210#[derive(Debug, Default, Clone, Deserialize, Serialize)]
211struct Subpattern {
212    /// The minimum number of characters that can be consumed by the wildcard expression.
213    consume: usize,
214    /// Whether the wildcard expression can consume an arbitrary number of characters.
215    many: bool,
216    /// A string literal that is expected after the wildcards.
217    suffix: String,
218}
219
220impl Subpattern {
221    /// Converts a Subpattern to an equivalent regular expression and writes it to a given string.
222    fn write_regex_to(&self, r: &mut String) {
223        match self.consume {
224            0 => {
225                if self.many {
226                    r.push_str(".*");
227                }
228            }
229            1 => {
230                r.push('.');
231                if self.many {
232                    r.push('+');
233                }
234            }
235            n => {
236                r.push_str(".{");
237                write!(r, "{}", n);
238                if self.many {
239                    r.push(',');
240                }
241                r.push('}');
242            }
243        }
244        regex_syntax::escape_into(&self.suffix, r);
245    }
246}
247
248fn is_match_subpatterns(subpatterns: &[Subpattern], mut text: &str) -> bool {
249    let (subpattern, subpatterns) = match subpatterns {
250        [] => return text.is_empty(),
251        [subpattern, subpatterns @ ..] => (subpattern, subpatterns),
252    };
253    // Go ahead and skip the minimum number of characters the sub-pattern consumes:
254    if subpattern.consume > 0 {
255        let mut chars = text.chars();
256        if chars.nth(subpattern.consume - 1).is_none() {
257            return false;
258        }
259        text = chars.as_str();
260    }
261    if subpattern.many {
262        // The sub-pattern might consume any number of characters, but we need to find
263        // where it terminates so we can match any subsequent sub-patterns. We do this
264        // by searching for the suffix string using str::find.
265        //
266        // We could investigate using a fancier substring search like Boyer-Moore:
267        // https://en.wikipedia.org/wiki/Boyer%E2%80%93Moore_string-search_algorithm
268        //
269        // .. but it's likely not worth it. It's slower for small strings,
270        // and doesn't really start outperforming the naive approach until
271        // haystack sizes of 1KB or greater. See benchmarking results from:
272        // https://github.com/killerswan/boyer-moore-search/blob/master/README.md
273        //
274        // Another approach that may be interesting to look at is a
275        // hardware-optimized search:
276        // http://0x80.pl/articles/simd-strfind.html
277        if subpattern.suffix.len() == 0 {
278            // Nothing to find... This should only happen in the last sub-pattern.
279            assert!(
280                subpatterns.is_empty(),
281                "empty suffix in middle of a pattern"
282            );
283            return true;
284        }
285        // Use rfind so we perform a greedy capture, like Regex.
286        let mut found = text.rfind(&subpattern.suffix);
287        loop {
288            match found {
289                None => return false,
290                Some(offset) => {
291                    let mut end = offset + subpattern.suffix.len();
292                    if is_match_subpatterns(subpatterns, &text[end..]) {
293                        return true;
294                    }
295                    // Didn't match, look for the next rfind.
296                    if offset == 0 {
297                        return false;
298                    }
299                    // Find the previous valid char byte.
300                    loop {
301                        end -= 1;
302                        if text.is_char_boundary(end) {
303                            break;
304                        }
305                    }
306                    found = text[..end].rfind(&subpattern.suffix);
307                }
308            }
309        }
310    }
311    // No string search needed, we just use a prefix match on rest.
312    if !text.starts_with(&subpattern.suffix) {
313        return false;
314    }
315    is_match_subpatterns(subpatterns, &text[subpattern.suffix.len()..])
316}
317
318/// Breaks a LIKE pattern into a chain of sub-patterns.
319fn build_subpatterns(pattern: &str) -> Result<Vec<Subpattern>, EvalError> {
320    let mut subpatterns = Vec::with_capacity(MAX_SUBPATTERNS);
321    let mut current = Subpattern::default();
322    let mut in_wildcard = true;
323    let mut in_escape = false;
324    for c in pattern.chars() {
325        match c {
326            c if !in_escape && c == DEFAULT_ESCAPE => {
327                in_escape = true;
328                in_wildcard = false;
329            }
330            '_' if !in_escape => {
331                if !in_wildcard {
332                    current.suffix.shrink_to_fit();
333                    subpatterns.push(mem::take(&mut current));
334                    in_wildcard = true;
335                }
336                current.consume += 1;
337            }
338            '%' if !in_escape => {
339                if !in_wildcard {
340                    current.suffix.shrink_to_fit();
341                    subpatterns.push(mem::take(&mut current));
342                    in_wildcard = true;
343                }
344                current.many = true;
345            }
346            c => {
347                current.suffix.push(c);
348                in_escape = false;
349                in_wildcard = false;
350            }
351        }
352    }
353    if in_escape {
354        return Err(EvalError::UnterminatedLikeEscapeSequence);
355    }
356    current.suffix.shrink_to_fit();
357    subpatterns.push(current);
358    subpatterns.shrink_to_fit();
359    Ok(subpatterns)
360}
361
362/// Builds a regular expression that matches some parsed Subpatterns.
363fn build_regex(subpatterns: &[Subpattern], case_insensitive: bool) -> Result<Regex, EvalError> {
364    let mut r = String::from("^");
365    for sp in subpatterns {
366        sp.write_regex_to(&mut r);
367    }
368    r.push('$');
369    match Regex::new(&r, case_insensitive) {
370        Ok(regex) => Ok(regex),
371        Err(RegexCompilationError::PatternTooLarge { .. }) => Err(EvalError::LikePatternTooLong),
372        Err(RegexCompilationError::TooManyCharacterClasses { .. }) => {
373            Err(EvalError::LikePatternTooLong)
374        }
375        Err(RegexCompilationError::RegexError(regex::Error::CompiledTooBig(_))) => {
376            Err(EvalError::LikePatternTooLong)
377        }
378        Err(e) => Err(EvalError::Internal(
379            format!("build_regex produced invalid regex: {}", e).into(),
380        )),
381    }
382}
383
384// Unit Tests
385//
386// Most of the unit tests for LIKE and ILIKE can be found in:
387//    test/sqllogictest/cockroach/like.slt
388// These tests are here as a convenient place to run quick tests while
389// actively working on changes to the implementation. Make sure you
390// run the full test suite before submitting any changes.
391
392#[cfg(test)]
393mod test {
394    use super::*;
395
396    #[mz_ore::test]
397    fn test_normalize_pattern() {
398        struct TestCase<'a> {
399            pattern: &'a str,
400            escape: EscapeBehavior,
401            expected: &'a str,
402        }
403        let test_cases = vec![
404            TestCase {
405                pattern: "",
406                escape: EscapeBehavior::Disabled,
407                expected: "",
408            },
409            TestCase {
410                pattern: "ban%na!",
411                escape: EscapeBehavior::default(),
412                expected: "ban%na!",
413            },
414            TestCase {
415                pattern: "ban%%%na!",
416                escape: EscapeBehavior::Char('%'),
417                expected: "ban\\%\\na!",
418            },
419            TestCase {
420                pattern: "ban%na\\!",
421                escape: EscapeBehavior::Char('n'),
422                expected: "ba\\%\\a\\\\!",
423            },
424            TestCase {
425                pattern: "ban%na\\!",
426                escape: EscapeBehavior::Disabled,
427                expected: "ban%na\\\\!",
428            },
429            TestCase {
430                pattern: "ban\\na!",
431                escape: EscapeBehavior::Char('n'),
432                expected: "ba\\\\\\a!",
433            },
434            TestCase {
435                pattern: "ban\\\\na!",
436                escape: EscapeBehavior::Char('n'),
437                expected: "ba\\\\\\\\\\a!",
438            },
439            TestCase {
440                pattern: "food",
441                escape: EscapeBehavior::Char('o'),
442                expected: "f\\od",
443            },
444            TestCase {
445                pattern: "漢漢",
446                escape: EscapeBehavior::Char('漢'),
447                expected: "\\漢",
448            },
449        ];
450
451        for input in test_cases {
452            let actual = normalize_pattern(input.pattern, input.escape).unwrap();
453            assert!(
454                actual == input.expected,
455                "normalize_pattern({:?}, {:?}):\n\tactual: {:?}\n\texpected: {:?}\n",
456                input.pattern,
457                input.escape,
458                actual,
459                input.expected,
460            );
461        }
462    }
463
464    #[mz_ore::test]
465    fn test_escape_too_long() {
466        match EscapeBehavior::from_str("foo") {
467            Err(EvalError::LikeEscapeTooLong) => {}
468            _ => {
469                panic!("expected error when using escape string with >1 character");
470            }
471        }
472    }
473
474    #[mz_ore::test]
475    fn test_like() {
476        struct Input<'a> {
477            haystack: &'a str,
478            matches: bool,
479        }
480        let input = |haystack, matches| Input { haystack, matches };
481        struct Pattern<'a> {
482            needle: &'a str,
483            case_insensitive: bool,
484            inputs: Vec<Input<'a>>,
485        }
486        let test_cases = vec![
487            Pattern {
488                needle: "ban%na!",
489                case_insensitive: false,
490                inputs: vec![input("banana!", true)],
491            },
492            Pattern {
493                needle: "foo",
494                case_insensitive: true,
495                inputs: vec![
496                    input("", false),
497                    input("f", false),
498                    input("fo", false),
499                    input("foo", true),
500                    input("FOO", true),
501                    input("Foo", true),
502                    input("fOO", true),
503                    input("food", false),
504                ],
505            },
506        ];
507
508        for tc in test_cases {
509            let matcher = compile(tc.needle, tc.case_insensitive).unwrap();
510            for input in tc.inputs {
511                let actual = matcher.is_match(input.haystack);
512                assert!(
513                    actual == input.matches,
514                    "{:?} {} {:?}:\n\tactual: {:?}\n\texpected: {:?}\n",
515                    input.haystack,
516                    match tc.case_insensitive {
517                        true => "ILIKE",
518                        false => "LIKE",
519                    },
520                    tc.needle,
521                    actual,
522                    input.matches,
523                );
524            }
525        }
526    }
527
528    // Patterns with two or more `%` wildcards now compile to the linear regex
529    // matcher instead of the back-tracking string matcher, which was
530    // super-linear: an adversarial pattern like `%a%a%a` against a long run of
531    // `a`s took time proportional to `len(text)^(number of %)`. Verify the
532    // routing change still yields correct match results (the regex matcher and
533    // the string matcher must agree), including the previously-pathological
534    // shape, which now completes instantly regardless of text length.
535    #[mz_ore::test]
536    fn test_many_wildcards_match_correctly() {
537        let long_a = "a".repeat(64);
538        let cases: &[(&str, &str, bool)] = &[
539            ("%a%a%a", "xaxaxa", true),
540            ("%a%a%a", "aaa", true),
541            ("%a%a%a", "aa", false),
542            ("%a%b%c%", "zzabqcz", true),
543            ("%a%b%c%", "cba", false),
544            ("a%b%c", "abc", true),
545            ("a%b%c", "axxbxxc", true),
546            ("a%b%c", "abcd", false),
547            // A single `%` still uses the (near-linear) string matcher.
548            ("%_%_%", "ab", true),
549            ("%%%%", "", true),
550            // The exact pathological shape from the fuzzer; super-linear before
551            // the fix, instant after.
552            ("%a%a%a%a%a", &long_a, true),
553            ("%a%a%a%a%a", "aaaa", false),
554        ];
555        for (pat, text, expected) in cases {
556            let m = compile(pat, false).unwrap();
557            assert_eq!(
558                m.is_match(text),
559                *expected,
560                "pattern {pat:?} against {text:?}"
561            );
562        }
563    }
564
565    // Only `%` subpatterns with a non-empty suffix nest the back-tracking loops,
566    // so only two or more of *those* should reroute to the regex engine. A `%`
567    // with an empty suffix (always trailing) short-circuits without back-tracking
568    // and must not count. In particular the ubiquitous "contains"/prefix/suffix
569    // shapes (`%foo%`, `%foo`, `foo%`) must stay on the fast string matcher, where
570    // a single `rfind` is far cheaper than a forward regex scan. Lock the routing
571    // decision in so a future tweak to the predicate can't silently de-optimize
572    // the common case.
573    #[mz_ore::test]
574    fn test_string_matcher_routing() {
575        let uses_regex = |pat: &str| -> bool {
576            match compile(pat, false).unwrap().matcher_impl {
577                MatcherImpl::String(_) => false,
578                MatcherImpl::Regex(_) => true,
579            }
580        };
581        // Fast string-matcher path: at most one back-tracking `%`. The trailing
582        // `%` in `%foo%` has an empty suffix and short-circuits, so it does not
583        // count.
584        for pat in ["%foo%", "%foo", "foo%", "foo", "f_o", "%_%_%", "%%%%"] {
585            assert!(
586                !uses_regex(pat),
587                "pattern {pat:?} should use the fast string matcher"
588            );
589        }
590        // Regex path: two or more non-empty-suffix `%`, which are super-linear in
591        // the string matcher.
592        for pat in ["%foo%bar%", "%foo%bar", "a%b%c", "%a%a%a"] {
593            assert!(
594                uses_regex(pat),
595                "pattern {pat:?} should use the regex engine"
596            );
597        }
598        // Case-insensitive always uses regex, regardless of `%` count.
599        assert!(matches!(
600            compile("%foo%", true).unwrap().matcher_impl,
601            MatcherImpl::Regex(_)
602        ));
603    }
604}