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}