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}