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::RegexError(regex::Error::CompiledTooBig(_))) => {
373            Err(EvalError::LikePatternTooLong)
374        }
375        Err(e) => Err(EvalError::Internal(
376            format!("build_regex produced invalid regex: {}", e).into(),
377        )),
378    }
379}
380
381// Unit Tests
382//
383// Most of the unit tests for LIKE and ILIKE can be found in:
384//    test/sqllogictest/cockroach/like.slt
385// These tests are here as a convenient place to run quick tests while
386// actively working on changes to the implementation. Make sure you
387// run the full test suite before submitting any changes.
388
389#[cfg(test)]
390mod test {
391    use super::*;
392
393    #[mz_ore::test]
394    fn test_normalize_pattern() {
395        struct TestCase<'a> {
396            pattern: &'a str,
397            escape: EscapeBehavior,
398            expected: &'a str,
399        }
400        let test_cases = vec![
401            TestCase {
402                pattern: "",
403                escape: EscapeBehavior::Disabled,
404                expected: "",
405            },
406            TestCase {
407                pattern: "ban%na!",
408                escape: EscapeBehavior::default(),
409                expected: "ban%na!",
410            },
411            TestCase {
412                pattern: "ban%%%na!",
413                escape: EscapeBehavior::Char('%'),
414                expected: "ban\\%\\na!",
415            },
416            TestCase {
417                pattern: "ban%na\\!",
418                escape: EscapeBehavior::Char('n'),
419                expected: "ba\\%\\a\\\\!",
420            },
421            TestCase {
422                pattern: "ban%na\\!",
423                escape: EscapeBehavior::Disabled,
424                expected: "ban%na\\\\!",
425            },
426            TestCase {
427                pattern: "ban\\na!",
428                escape: EscapeBehavior::Char('n'),
429                expected: "ba\\\\\\a!",
430            },
431            TestCase {
432                pattern: "ban\\\\na!",
433                escape: EscapeBehavior::Char('n'),
434                expected: "ba\\\\\\\\\\a!",
435            },
436            TestCase {
437                pattern: "food",
438                escape: EscapeBehavior::Char('o'),
439                expected: "f\\od",
440            },
441            TestCase {
442                pattern: "漢漢",
443                escape: EscapeBehavior::Char('漢'),
444                expected: "\\漢",
445            },
446        ];
447
448        for input in test_cases {
449            let actual = normalize_pattern(input.pattern, input.escape).unwrap();
450            assert!(
451                actual == input.expected,
452                "normalize_pattern({:?}, {:?}):\n\tactual: {:?}\n\texpected: {:?}\n",
453                input.pattern,
454                input.escape,
455                actual,
456                input.expected,
457            );
458        }
459    }
460
461    #[mz_ore::test]
462    fn test_escape_too_long() {
463        match EscapeBehavior::from_str("foo") {
464            Err(EvalError::LikeEscapeTooLong) => {}
465            _ => {
466                panic!("expected error when using escape string with >1 character");
467            }
468        }
469    }
470
471    #[mz_ore::test]
472    fn test_like() {
473        struct Input<'a> {
474            haystack: &'a str,
475            matches: bool,
476        }
477        let input = |haystack, matches| Input { haystack, matches };
478        struct Pattern<'a> {
479            needle: &'a str,
480            case_insensitive: bool,
481            inputs: Vec<Input<'a>>,
482        }
483        let test_cases = vec![
484            Pattern {
485                needle: "ban%na!",
486                case_insensitive: false,
487                inputs: vec![input("banana!", true)],
488            },
489            Pattern {
490                needle: "foo",
491                case_insensitive: true,
492                inputs: vec![
493                    input("", false),
494                    input("f", false),
495                    input("fo", false),
496                    input("foo", true),
497                    input("FOO", true),
498                    input("Foo", true),
499                    input("fOO", true),
500                    input("food", false),
501                ],
502            },
503        ];
504
505        for tc in test_cases {
506            let matcher = compile(tc.needle, tc.case_insensitive).unwrap();
507            for input in tc.inputs {
508                let actual = matcher.is_match(input.haystack);
509                assert!(
510                    actual == input.matches,
511                    "{:?} {} {:?}:\n\tactual: {:?}\n\texpected: {:?}\n",
512                    input.haystack,
513                    match tc.case_insensitive {
514                        true => "ILIKE",
515                        false => "LIKE",
516                    },
517                    tc.needle,
518                    actual,
519                    input.matches,
520                );
521            }
522        }
523    }
524
525    // Patterns with two or more `%` wildcards now compile to the linear regex
526    // matcher instead of the back-tracking string matcher, which was
527    // super-linear: an adversarial pattern like `%a%a%a` against a long run of
528    // `a`s took time proportional to `len(text)^(number of %)`. Verify the
529    // routing change still yields correct match results (the regex matcher and
530    // the string matcher must agree), including the previously-pathological
531    // shape, which now completes instantly regardless of text length.
532    #[mz_ore::test]
533    fn test_many_wildcards_match_correctly() {
534        let long_a = "a".repeat(64);
535        let cases: &[(&str, &str, bool)] = &[
536            ("%a%a%a", "xaxaxa", true),
537            ("%a%a%a", "aaa", true),
538            ("%a%a%a", "aa", false),
539            ("%a%b%c%", "zzabqcz", true),
540            ("%a%b%c%", "cba", false),
541            ("a%b%c", "abc", true),
542            ("a%b%c", "axxbxxc", true),
543            ("a%b%c", "abcd", false),
544            // A single `%` still uses the (near-linear) string matcher.
545            ("%_%_%", "ab", true),
546            ("%%%%", "", true),
547            // The exact pathological shape from the fuzzer; super-linear before
548            // the fix, instant after.
549            ("%a%a%a%a%a", &long_a, true),
550            ("%a%a%a%a%a", "aaaa", false),
551        ];
552        for (pat, text, expected) in cases {
553            let m = compile(pat, false).unwrap();
554            assert_eq!(
555                m.is_match(text),
556                *expected,
557                "pattern {pat:?} against {text:?}"
558            );
559        }
560    }
561
562    // Only `%` subpatterns with a non-empty suffix nest the back-tracking loops,
563    // so only two or more of *those* should reroute to the regex engine. A `%`
564    // with an empty suffix (always trailing) short-circuits without back-tracking
565    // and must not count. In particular the ubiquitous "contains"/prefix/suffix
566    // shapes (`%foo%`, `%foo`, `foo%`) must stay on the fast string matcher, where
567    // a single `rfind` is far cheaper than a forward regex scan. Lock the routing
568    // decision in so a future tweak to the predicate can't silently de-optimize
569    // the common case.
570    #[mz_ore::test]
571    fn test_string_matcher_routing() {
572        let uses_regex = |pat: &str| -> bool {
573            match compile(pat, false).unwrap().matcher_impl {
574                MatcherImpl::String(_) => false,
575                MatcherImpl::Regex(_) => true,
576            }
577        };
578        // Fast string-matcher path: at most one back-tracking `%`. The trailing
579        // `%` in `%foo%` has an empty suffix and short-circuits, so it does not
580        // count.
581        for pat in ["%foo%", "%foo", "foo%", "foo", "f_o", "%_%_%", "%%%%"] {
582            assert!(
583                !uses_regex(pat),
584                "pattern {pat:?} should use the fast string matcher"
585            );
586        }
587        // Regex path: two or more non-empty-suffix `%`, which are super-linear in
588        // the string matcher.
589        for pat in ["%foo%bar%", "%foo%bar", "a%b%c", "%a%a%a"] {
590            assert!(
591                uses_regex(pat),
592                "pattern {pat:?} should use the regex engine"
593            );
594        }
595        // Case-insensitive always uses regex, regardless of `%` count.
596        assert!(matches!(
597            compile("%foo%", true).unwrap().matcher_impl,
598            MatcherImpl::Regex(_)
599        ));
600    }
601}