diff --git a/encodings/fsst/benches/fsst_like.rs b/encodings/fsst/benches/fsst_like.rs index 0737100602b..8521d64330a 100644 --- a/encodings/fsst/benches/fsst_like.rs +++ b/encodings/fsst/benches/fsst_like.rs @@ -100,6 +100,21 @@ impl Dataset { } } + /// Suffixes that actually occur in each generator, so the arm exercises both the + /// reject and the accept path. Match rates: rare 0%, email 9.1%, urls 9.5%, + /// cb 10.3%, path 10.6%, log 16.8%, json 100%. + fn suffix_pattern(&self) -> &'static str { + match self { + Self::Urls => "%index.html", + Self::Cb => "%reviews", + Self::Log => "%bot.html)\"", + Self::Json => "%}", + Self::Path => "%main.rs", + Self::Email => "%gmail.com", + Self::Rare => "%xyzzy", + } + } + fn contains_pattern(&self) -> &'static str { match self { Self::Urls => "%google%", @@ -128,6 +143,29 @@ fn bench_like(bencher: Bencher, fsst: &FSSTArray, pattern: &str) { }); } +/// The decompress-then-compare path the kernel falls back to when a pattern cannot be +/// pushed down, for comparison against the arms above. +/// +/// Canonicalizing first is what the fallback does: for a constant pattern the scalar fn +/// runs `execute::` on the haystack and then evaluates over the views. +/// Doing it here keeps both legs in one bench binary, so the comparison needs no source +/// edit to reproduce. +fn bench_like_canonicalize(bencher: Bencher, fsst: &FSSTArray, pattern: &str) { + let len = fsst.len(); + let arr = fsst.clone().into_array(); + let pattern = ConstantArray::new(pattern, len).into_array(); + bencher + .with_inputs(|| SESSION.create_execution_ctx()) + .bench_refs(|ctx| { + let canonical = arr.clone().execute::(ctx).unwrap().into_array(); + Like::try_new(canonical, pattern.clone(), LikeOptions::default()) + .unwrap() + .into_array() + .execute::(ctx) + .unwrap() + }); +} + #[divan::bench(args = [ Dataset::Urls, Dataset::Cb, Dataset::Log, Dataset::Json, Dataset::Path, Dataset::Email, Dataset::Rare, @@ -143,3 +181,19 @@ fn fsst_prefix(bencher: Bencher, dataset: &Dataset) { fn fsst_contains(bencher: Bencher, dataset: &Dataset) { bench_like(bencher, dataset.fsst_array(), dataset.contains_pattern()); } + +#[divan::bench(args = [ + Dataset::Urls, Dataset::Cb, Dataset::Log, Dataset::Json, + Dataset::Path, Dataset::Email, Dataset::Rare, +])] +fn fsst_suffix(bencher: Bencher, dataset: &Dataset) { + bench_like(bencher, dataset.fsst_array(), dataset.suffix_pattern()); +} + +#[divan::bench(args = [ + Dataset::Urls, Dataset::Cb, Dataset::Log, Dataset::Json, + Dataset::Path, Dataset::Email, Dataset::Rare, +])] +fn fsst_suffix_canonicalize(bencher: Bencher, dataset: &Dataset) { + bench_like_canonicalize(bencher, dataset.fsst_array(), dataset.suffix_pattern()); +} diff --git a/encodings/fsst/src/compute/like.rs b/encodings/fsst/src/compute/like.rs index 388cdb342dc..5a0c86dc90c 100644 --- a/encodings/fsst/src/compute/like.rs +++ b/encodings/fsst/src/compute/like.rs @@ -92,6 +92,7 @@ mod tests { use vortex_array::scalar_fn::fns::like::Like; use vortex_array::scalar_fn::fns::like::LikeKernel; use vortex_array::scalar_fn::fns::like::LikeOptions; + use vortex_error::VortexExpect; use vortex_error::VortexResult; use vortex_session::VortexSession; @@ -282,6 +283,42 @@ mod tests { Ok(()) } + /// `%suffix` must be evaluated by the kernel, not handed back for + /// decompression. Asserting the result is `Some` is what distinguishes + /// pushdown from the fallback path — the boolean answer is the same either way. + #[test] + fn test_like_kernel_pushes_down_suffix() -> VortexResult<()> { + let fsst = make_fsst( + &[Some("abc"), Some("xabc"), Some("abcx")], + Nullability::NonNullable, + ); + let mut ctx = SESSION.create_execution_ctx(); + let fsst_v = fsst.as_view(); + + let pattern = ConstantArray::new("%abc", fsst.len()).into_array(); + let result = + ::like(fsst_v, &pattern, LikeOptions::default(), &mut ctx)? + .vortex_expect("suffix pattern must be pushed down, not fall back"); + let expected = BoolArray::from_iter([true, true, false]); + assert_arrays_eq!(&result, &expected, &mut ctx); + + // Negated form goes through the same matcher. + let result = ::like( + fsst_v, + &pattern, + LikeOptions { + negated: true, + case_insensitive: false, + }, + &mut ctx, + )? + .vortex_expect("negated suffix pattern must be pushed down"); + let expected = BoolArray::from_iter([false, false, true]); + assert_arrays_eq!(&result, &expected, &mut ctx); + + Ok(()) + } + /// Patterns we can't handle should return `None` (fall back). #[test] fn test_like_kernel_falls_back_for_complex_pattern() -> VortexResult<()> { @@ -304,11 +341,11 @@ mod tests { let result = ::like(fsst_v, &pattern, opts, &mut ctx)?; assert!(result.is_none(), "ilike should fall back"); - // Suffix patterns are still unsupported, even when the suffix is an escaped literal. - let pattern = ConstantArray::new(r"%\%", fsst.len()).into_array(); + // A `%` in the middle is none of prefix, contains or suffix. + let pattern = ConstantArray::new("a%b", fsst.len()).into_array(); let result = ::like(fsst_v, &pattern, LikeOptions::default(), &mut ctx)?; - assert!(result.is_none(), "escaped suffix pattern should fall back"); + assert!(result.is_none(), "mid-pattern % should fall back"); Ok(()) } diff --git a/encodings/fsst/src/dfa/mod.rs b/encodings/fsst/src/dfa/mod.rs index 5f67f92997e..1bb55b1737c 100644 --- a/encodings/fsst/src/dfa/mod.rs +++ b/encodings/fsst/src/dfa/mod.rs @@ -1,25 +1,26 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors -//! # FSST LIKE Pushdown via DFA Construction +//! # FSST LIKE Pushdown //! -//! This module implements DFA-based pattern matching directly on FSST-compressed -//! strings, without decompressing them. It handles two pattern shapes: +//! This module implements pattern matching directly on FSST-compressed strings, +//! without decompressing them. It handles three pattern shapes: //! //! - **Prefix**: `'prefix%'` — matches strings starting with a literal prefix. //! - **Contains**: `'%needle%'` — matches strings containing a literal substring. +//! - **Suffix**: `'%suffix'` — matches strings ending with a literal suffix. //! //! Pushdown is intentionally conservative. If the pattern shape is unsupported, //! or if the pattern exceeds the DFA's representable state space, construction //! returns `None` and the caller must fall back to ordinary decompression-based //! LIKE evaluation. //! -//! TODO(joe): suffix (`'%suffix'`) pushdown. Two approaches: -//! - **Forward DFA**: use a non-sticky accept state with KMP fallback transitions, -//! check `state == accept` after processing all codes. Branchless and vectorizable. -//! - **Backward scan**: walk the compressed code stream in reverse, comparing symbol -//! bytes from the end. Simpler, no DFA construction, but requires reverse parsing -//! of the FSST escape mechanism. +//! Prefix and contains are DFAs over the code stream, described below. Suffix is not: +//! a forward DFA cannot stop early, because a suffix match is only decided at the last +//! code, and that measured slower than decompressing. [`suffix::SuffixMatcher`] takes the +//! other route the original TODO sketched and walks the code stream backward from each +//! row's end; the reverse parsing that route needs turns out to be local, and that module +//! explains why. //! //! ## Background: FSST Encoding //! @@ -108,7 +109,7 @@ //! //! ## State-Space Limits //! -//! The public behavior is shaped by two implementation limits, both measured in +//! The public behavior is shaped by three implementation limits, all measured in //! pattern **bytes** rather than Unicode scalar values: //! //! - `prefix%` pushdown is limited to **253 bytes**. The flat prefix DFA uses @@ -117,12 +118,16 @@ //! - `%needle%` pushdown is limited to **254 bytes**. The contains DFA stores //! states in `u8`, so it needs room for every match-progress state plus both //! the accept state and the escape sentinel. +//! - `%suffix` pushdown is limited to **254 bytes**. The tail matcher compares +//! bytes rather than holding states, so this bound only keeps it in step with +//! the other two. //! //! Patterns beyond those limits are still valid LIKE patterns; they simply do //! not use FSST pushdown and must be evaluated through the fallback path. mod flat_contains; mod prefix; +mod suffix; #[cfg(test)] mod tests; @@ -132,6 +137,7 @@ use flat_contains::FlatContainsDfa; use fsst::ESCAPE_CODE; use fsst::Symbol; use prefix::FlatPrefixDfa; +use suffix::SuffixMatcher; use vortex_buffer::BitBuffer; use vortex_error::VortexExpect; use vortex_error::VortexResult; @@ -154,6 +160,7 @@ enum MatcherInner { MatchAll, Prefix(FlatPrefixDfa), Contains(FlatContainsDfa), + Suffix(SuffixMatcher), } impl FsstMatcher { @@ -161,7 +168,7 @@ impl FsstMatcher { /// /// Returns `Ok(None)` if the pattern shape is not supported for pushdown /// (e.g. `_` wildcards, multiple non-bookend `%`, `prefix%` longer than - /// 253 bytes, or `%needle%` longer than 254 bytes). + /// 253 bytes, or `%needle%`/`%suffix` longer than 254 bytes). pub(crate) fn try_new( symbols: &[Symbol], symbol_lengths: &[u8], @@ -172,7 +179,9 @@ impl FsstMatcher { }; let inner = match like_kind { - LikeKind::Prefix(pattern) | LikeKind::Contains(pattern) if pattern.is_empty() => { + LikeKind::Prefix(pattern) | LikeKind::Contains(pattern) | LikeKind::Suffix(pattern) + if pattern.is_empty() => + { MatcherInner::MatchAll } LikeKind::Prefix(prefix) => { @@ -195,6 +204,16 @@ impl FsstMatcher { needle.as_ref(), )?) } + LikeKind::Suffix(suffix) => { + if suffix.len() > SuffixMatcher::MAX_SUFFIX_LEN { + return Ok(None); + } + MatcherInner::Suffix(SuffixMatcher::new( + symbols, + symbol_lengths, + suffix.as_ref(), + )?) + } }; Ok(Some(Self { inner })) @@ -206,6 +225,7 @@ impl FsstMatcher { MatcherInner::MatchAll => true, MatcherInner::Prefix(dfa) => dfa.matches(codes), MatcherInner::Contains(dfa) => dfa.matches(codes), + MatcherInner::Suffix(matcher) => matcher.matches(codes), } } } @@ -216,11 +236,15 @@ enum LikeKind<'a> { Prefix(Cow<'a, [u8]>), /// `%needle%` Contains(Cow<'a, [u8]>), + /// `%suffix` + Suffix(Cow<'a, [u8]>), } impl<'a> LikeKind<'a> { fn parse(pattern: &'a [u8]) -> Option { - Self::parse_prefix(pattern).or_else(|| Self::parse_contains(pattern)) + Self::parse_prefix(pattern) + .or_else(|| Self::parse_contains(pattern)) + .or_else(|| Self::parse_suffix(pattern)) } fn parse_prefix(pattern: &'a [u8]) -> Option { @@ -235,6 +259,46 @@ impl<'a> LikeKind<'a> { Self::parse_literal_until_final_percent(pattern, 1).map(LikeKind::Contains) } + fn parse_suffix(pattern: &'a [u8]) -> Option { + if !pattern.starts_with(b"%") { + return None; + } + + Self::parse_literal_to_end(pattern, 1).map(LikeKind::Suffix) + } + + /// Parse `pattern[literal_start..]` as a literal running to the end of the + /// pattern. Returns `None` if `_` or `%` is encountered, since either means + /// the tail is not a plain literal. + fn parse_literal_to_end(pattern: &'a [u8], literal_start: usize) -> Option> { + let mut literal: Option> = None; + let mut idx = literal_start; + while idx < pattern.len() { + match pattern[idx] { + b'\\' => { + // Trailing `\` is treated as a literal backslash. + let escaped = pattern.get(idx + 1).copied().unwrap_or(b'\\'); + literal + .get_or_insert_with(|| pattern[literal_start..idx].to_vec()) + .push(escaped); + idx = (idx + 2).min(pattern.len()); + } + b'%' | b'_' => return None, + byte => { + // No-op on the borrowed path; only push once we've started copying. + if let Some(literal) = &mut literal { + literal.push(byte); + } + idx += 1; + } + } + } + Some(match literal { + Some(buf) => Cow::Owned(buf), + None => Cow::Borrowed(&pattern[literal_start..]), + }) + } + /// Parse `pattern[literal_start..]` as a literal terminated by a single /// trailing `%`. Returns `None` if `_` or a non-final `%` is encountered. /// diff --git a/encodings/fsst/src/dfa/suffix.rs b/encodings/fsst/src/dfa/suffix.rs new file mode 100644 index 00000000000..4eff8a4442c --- /dev/null +++ b/encodings/fsst/src/dfa/suffix.rs @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Tail matcher for suffix matching (`LIKE '%suffix'`) on FSST codes. +//! +//! Prefix and contains matching win by exiting early: a prefix decides within the first +//! few codes, and contains stops at the first hit. A forward suffix scan has no such exit +//! — the answer depends on the last byte, so every code of every row gets touched. That +//! is the wrong asymptotics to bring against a vectorized decompress-and-compare, and it +//! measured slower. +//! +//! So this walks the code stream *backward* from the end. The array stores per-row +//! offsets, so each row's end is already known, and the first byte compared is the row's +//! last byte — a non-matching row is rejected after a single symbol. +//! +//! Backward walking needs token boundaries, which the forward escape mechanism appears to +//! hide. It does not, and only locally: ambiguity propagates through a run of consecutive +//! [`ESCAPE_CODE`] bytes and stops at the first byte that is not one. Given a known token +//! boundary `p`, let `r` be the number of consecutive `ESCAPE_CODE` bytes immediately left +//! of `codes[p - 1]`: +//! +//! * `r` odd — `codes[p - 2]` is an escape marker, so `codes[p - 1]` is a literal byte. +//! * `r` even — `codes[p - 1]` is itself a symbol code. +//! +//! This holds because the first non-`ESCAPE_CODE` byte left of the run is either a symbol +//! code or an escaped literal, and in both cases the position after it is a token +//! boundary; the run in between pairs off into two-byte escaped-`0xFF` tokens. Real text +//! contains no `0xFF`, so `r` is 0 or 1 and each step is `O(1)`. +//! +//! Two precomputations then remove the per-row byte work: +//! +//! * `tail_step` answers "can a row ending in this symbol match, and how much of the +//! suffix does it cover" per code, so the common rejection is one byte lookup that never +//! reads the symbol's bytes. +//! * A symbol holds at most 8 bytes, so a whole symbol fits in a `u64`. Storing both the +//! symbols and the suffix *reversed* aligns the bytes each step has to compare at the low +//! end of a word, turning the comparison into `(a ^ b) & mask`. That replaces a +//! variable-length slice comparison with three ALU ops, which is what the walk past the +//! first symbol costs. + +use fsst::ESCAPE_CODE; +use fsst::Symbol; +use vortex_error::VortexResult; +use vortex_error::vortex_bail; + +/// A `tail_step` entry meaning no string ending in that symbol can match. +/// +/// Real entries are `min(symbol_len, suffix_len)`, which a symbol's 8-byte cap keeps +/// well below this. +const REJECT: u8 = u8::MAX; + +/// Number of byte values that can be a symbol code: [`ESCAPE_CODE`] is the one that cannot. +/// +/// Every per-code table is sized to this so a code past the symbol table, which only a +/// corrupt file produces, reads padding instead of indexing out of bounds — the same way +/// the prefix and contains DFAs absorb it in their 256-wide tables. +const CODE_SPACE: usize = ESCAPE_CODE as usize; + +/// Low-`n`-byte masks, indexed by how many bytes a step compares. +const TAKE_MASK: [u64; 9] = [ + 0x0000_0000_0000_0000, + 0x0000_0000_0000_00ff, + 0x0000_0000_0000_ffff, + 0x0000_0000_00ff_ffff, + 0x0000_0000_ffff_ffff, + 0x0000_00ff_ffff_ffff, + 0x0000_ffff_ffff_ffff, + 0x00ff_ffff_ffff_ffff, + 0xffff_ffff_ffff_ffff, +]; + +/// One FSST token, resolved backward from the end of a code stream. +enum Token { + /// An escaped byte, standing for itself. + Literal(u8), + /// A symbol table code. + Code(u8), +} + +/// Matches FSST-compressed strings that end with a fixed byte string. +pub(crate) struct SuffixMatcher { + suffix_len: usize, + /// Decoded byte length of each symbol, indexed by code, `0` past the table. + symbol_lengths: Vec, + /// Each symbol's decoded bytes, last byte first, packed little-endian. + symbol_rev: Vec, + /// For each count of still-unmatched suffix bytes, those bytes last-first. + suffix_rev: Vec, + /// Suffix bytes accounted for by a row's final symbol, or [`REJECT`]. + tail_step: Vec, +} + +/// Packs up to the last 8 of `bytes[..len]` into a `u64`, last byte in the low position. +fn pack_rev(bytes: &[u8]) -> u64 { + bytes + .iter() + .rev() + .take(8) + .enumerate() + .fold(0u64, |w, (j, &b)| w | (u64::from(b) << (8 * j))) +} + +impl SuffixMatcher { + /// The needle is compared byte-wise; this bound only keeps it in step with the + /// other matchers. + pub(crate) const MAX_SUFFIX_LEN: usize = u8::MAX as usize - 1; + + pub(crate) fn new( + symbols: &[Symbol], + symbol_lengths: &[u8], + suffix: &[u8], + ) -> VortexResult { + if suffix.len() > Self::MAX_SUFFIX_LEN { + vortex_bail!( + "suffix length {} exceeds maximum {} for suffix matching", + suffix.len(), + Self::MAX_SUFFIX_LEN + ); + } + + let decoded: Vec<[u8; 8]> = symbols.iter().map(|s| s.to_u64().to_le_bytes()).collect(); + let bytes_of = |code: usize| match (decoded.get(code), symbol_lengths.get(code)) { + // A length over 8 cannot describe a symbol, so clamp rather than slice past it. + (Some(bytes), Some(&len)) => &bytes[..usize::from(len).min(8)], + _ => &[][..], + }; + + let mut symbol_lengths = symbol_lengths.to_vec(); + symbol_lengths.resize(CODE_SPACE, 0); + + Ok(Self { + suffix_len: suffix.len(), + symbol_lengths, + symbol_rev: (0..CODE_SPACE).map(|c| pack_rev(bytes_of(c))).collect(), + suffix_rev: (0..=suffix.len()).map(|r| pack_rev(&suffix[..r])).collect(), + tail_step: (0..CODE_SPACE) + .map(|c| Self::tail_step_for(bytes_of(c), suffix)) + .collect(), + }) + } + + /// How much of `suffix` a row ending in `symbol` accounts for, or [`REJECT`]. + /// + /// A symbol at least as long as the suffix settles the match on its own; a shorter + /// one has to be the suffix's own tail, and leaves the rest to earlier tokens. + fn tail_step_for(symbol: &[u8], suffix: &[u8]) -> u8 { + if symbol.is_empty() { + // Padding past the symbol table, or a zero-length symbol a valid file cannot + // hold. Either way no row ending there matches. + return REJECT; + } + // A symbol holds at most 8 bytes, so the overlap is always well below `REJECT` + // and this conversion cannot fail. + let Ok(overlap) = u8::try_from(symbol.len().min(suffix.len())) else { + return REJECT; + }; + let n = usize::from(overlap); + if symbol[symbol.len() - n..] == suffix[suffix.len() - n..] { + overlap + } else { + REJECT + } + } + + /// The token ending at `p`, and the boundary that precedes it. + /// + /// `p` must be a token boundary. `None` means the stream is truncated: an escape + /// marker with no byte after it. + /// + /// The escape-run scan is `O(r)` for a run of `r` consecutive `ESCAPE_CODE` bytes, + /// which only a row built from literal `0xFF` bytes can make long. + #[inline] + fn token_before(codes: &[u8], p: usize) -> Option<(Token, usize)> { + let last = codes[p - 1]; + + let mut escapes = 0usize; + let mut q = p - 1; + while q > 0 && codes[q - 1] == ESCAPE_CODE { + escapes += 1; + q -= 1; + } + + if escapes % 2 == 1 { + // `codes[p - 2]` escapes it, so the byte stands for itself. + Some((Token::Literal(last), p - 2)) + } else if last == ESCAPE_CODE { + None + } else { + Some((Token::Code(last), p - 1)) + } + } + + /// A token's decoded bytes, last byte first, and how many of them there are. + #[inline] + fn rev_word(&self, token: &Token) -> (u64, usize) { + match *token { + Token::Literal(byte) => (u64::from(byte), 1), + Token::Code(code) => ( + self.symbol_rev[usize::from(code)], + usize::from(self.symbol_lengths[usize::from(code)]), + ), + } + } + + pub(crate) fn matches(&self, codes: &[u8]) -> bool { + let k = self.suffix_len; + if k == 0 { + return true; + } + if codes.is_empty() { + return false; + } + + // The final token decides most rows, and for a symbol `tail_step` decides it from + // the code alone. + let Some((token, next)) = Self::token_before(codes, codes.len()) else { + return false; + }; + let step = match token { + Token::Code(code) => self.tail_step[usize::from(code)], + Token::Literal(byte) if u64::from(byte) == (self.suffix_rev[k] & 0xff) => 1, + Token::Literal(_) => REJECT, + }; + if step == REJECT { + return false; + } + + // Anything left of the suffix reaches back over earlier tokens. A symbol may + // reach past the suffix's start, in which case only its last `take` bytes are + // compared and the walk is done. + let mut remaining = k - usize::from(step); + let mut pos = next; + while remaining > 0 { + if pos == 0 { + // The decoded string is shorter than the suffix. + return false; + } + let Some((token, next)) = Self::token_before(codes, pos) else { + return false; + }; + let (word, len) = self.rev_word(&token); + let take = len.min(remaining); + if take == 0 { + // Only a padded code past the symbol table is zero-length, and it carries + // none of the suffix. + return false; + } + if (word ^ self.suffix_rev[remaining]) & TAKE_MASK[take] != 0 { + return false; + } + remaining -= take; + pos = next; + } + + true + } +} diff --git a/encodings/fsst/src/dfa/tests.rs b/encodings/fsst/src/dfa/tests.rs index 88506206fcc..83fdf484e46 100644 --- a/encodings/fsst/src/dfa/tests.rs +++ b/encodings/fsst/src/dfa/tests.rs @@ -18,6 +18,7 @@ use vortex_array::assert_arrays_eq; use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; use vortex_array::scalar_fn::fns::like::Like; +use vortex_array::scalar_fn::fns::like::LikeKernel; use vortex_array::scalar_fn::fns::like::LikeOptions; use vortex_error::VortexResult; use vortex_session::VortexSession; @@ -26,6 +27,8 @@ use super::FsstMatcher; use super::LikeKind; use super::flat_contains::FlatContainsDfa; use super::prefix::FlatPrefixDfa; +use super::suffix::SuffixMatcher; +use crate::FSST; use crate::FSSTArray; use crate::fsst_compress; use crate::fsst_train_compressor; @@ -68,6 +71,22 @@ fn assert_owned_prefix(pattern: &[u8], expected: &[u8]) { assert_eq!(actual.as_ref(), expected); } +fn assert_borrowed_suffix(pattern: &[u8], expected: &[u8]) { + let Some(LikeKind::Suffix(actual)) = LikeKind::parse(pattern) else { + panic!("expected borrowed suffix pattern"); + }; + assert!(matches!(actual, Cow::Borrowed(_))); + assert_eq!(actual.as_ref(), expected); +} + +fn assert_owned_suffix(pattern: &[u8], expected: &[u8]) { + let Some(LikeKind::Suffix(actual)) = LikeKind::parse(pattern) else { + panic!("expected owned suffix pattern"); + }; + assert!(matches!(actual, Cow::Owned(_))); + assert_eq!(actual.as_ref(), expected); +} + fn assert_borrowed_contains(pattern: &[u8], expected: &[u8]) { let Some(LikeKind::Contains(actual)) = LikeKind::parse(pattern) else { panic!("expected borrowed contains pattern"); @@ -88,6 +107,7 @@ fn assert_owned_contains(pattern: &[u8], expected: &[u8]) { fn test_like_kind_parse_plain_patterns() { assert_borrowed_prefix(b"http%", b"http"); assert_borrowed_contains(b"%needle%", b"needle"); + assert_borrowed_suffix(b"%suffix", b"suffix"); assert_borrowed_prefix(b"%", b""); } @@ -101,14 +121,20 @@ fn test_like_kind_parse_escaped_patterns() { assert_owned_contains(br"%\_%", b"_"); assert_owned_contains(br"%\\%", b"\\"); assert_owned_contains(br"%has\%middle%", b"has%middle"); + assert_owned_suffix(br"%\%", b"%"); + assert_owned_suffix(br"%\_", b"_"); + assert_owned_suffix(br"%has\%middle", b"has%middle"); } #[test] fn test_like_kind_parse_unsupported_patterns() { - assert!(LikeKind::parse(b"%suffix").is_none()); assert!(LikeKind::parse(b"a_c").is_none()); - assert!(LikeKind::parse(br"%\%").is_none()); assert!(LikeKind::parse(br"foo\%bar").is_none()); + // A `%` in the middle is neither prefix, contains, nor suffix. + assert!(LikeKind::parse(b"a%b").is_none()); + assert!(LikeKind::parse(b"%a%b").is_none()); + // `_` anywhere in the tail still disqualifies a suffix. + assert!(LikeKind::parse(b"%a_b").is_none()); } /// No symbols — all bytes escaped. Simplest case to see the two tables. @@ -251,6 +277,189 @@ fn test_contains_pushdown_len_254_with_escapes() { assert!(!matcher.matches(&escaped(&mismatch))); } +/// No symbols — every byte escaped, so every token the walk sees is a literal. +#[test] +fn test_suffix_matcher_no_symbols() -> VortexResult<()> { + let matcher = SuffixMatcher::new(&[], &[], b"ab")?; + + assert!(matcher.matches(&escaped(b"ab"))); + assert!(matcher.matches(&escaped(b"xab"))); + assert!(matcher.matches(&escaped(b"abab"))); + // Only the end matters: "ab" occurs, but not last. + assert!(!matcher.matches(&escaped(b"abx"))); + assert!(!matcher.matches(&escaped(b"a"))); + assert!(!matcher.matches(&escaped(b"ba"))); + assert!(!matcher.matches(&[])); + + Ok(()) +} + +/// A suffix whose bytes repeat, so the walk consumes the same byte value twice and the +/// two steps must be counted separately rather than collapsing. +#[test] +fn test_suffix_matcher_repeated_chars() -> VortexResult<()> { + let matcher = SuffixMatcher::new(&[], &[], b"aa")?; + + assert!(matcher.matches(&escaped(b"aa"))); + assert!(matcher.matches(&escaped(b"aaa"))); + assert!(matcher.matches(&escaped(b"baa"))); + assert!(!matcher.matches(&escaped(b"aab"))); + assert!(!matcher.matches(&escaped(b"a"))); + + Ok(()) +} + +/// With symbols — the suffix can be covered by one symbol, or straddle a symbol and a +/// literal. +/// +/// Symbol table: code 0 = "ab", code 1 = "ba" +/// Suffix: "ab" +#[test] +fn test_suffix_matcher_with_symbols() -> VortexResult<()> { + let symbols = [sym(b"ab"), sym(b"ba")]; + let lengths = [2u8, 2]; + let matcher = SuffixMatcher::new(&symbols, &lengths, b"ab")?; + + // "ab" as one symbol. + assert!(matcher.matches(&[0])); + // "abab": the last symbol alone carries the whole suffix. + assert!(matcher.matches(&[0, 0])); + // "abba" ends in "ba". + assert!(!matcher.matches(&[0, 1])); + // "ba" + escaped 'b' → "bab": the suffix straddles a symbol and a literal. + assert!(matcher.matches(&[1, ESCAPE_CODE, b'b'])); + // "ba" + escaped "ab" → "baab", matched from two literals. + assert!(matcher.matches(&[1, ESCAPE_CODE, b'a', ESCAPE_CODE, b'b'])); + // "ba" alone is a partial match only. + assert!(!matcher.matches(&[1])); + // "ab" + escaped 'a' → "aba". + assert!(!matcher.matches(&[0, ESCAPE_CODE, b'a'])); + + Ok(()) +} + +/// Backward walking has to tell a symbol code from an escaped literal that happens to +/// share its byte value. Only the parity of the `ESCAPE_CODE` run to the left does that, +/// and a literal `0xFF` is what makes the run longer than one. +#[test] +fn test_suffix_matcher_escape_run_parity() -> VortexResult<()> { + let symbols = [sym(b"ab")]; + let lengths = [2u8]; + let matcher = SuffixMatcher::new(&symbols, &lengths, b"ab")?; + + // One escape: the trailing 0 is the literal `0x00`, so the string is "\0", not "ab". + // Reading it as a code instead would decode "ab" and match. + assert!(!matcher.matches(&[ESCAPE_CODE, 0])); + // Two escapes: they pair off into a literal `0xFF`, leaving 0 to be symbol "ab". + assert!(matcher.matches(&[ESCAPE_CODE, ESCAPE_CODE, 0])); + // The run terminates at the first byte that is not `ESCAPE_CODE`: "ab" + literal + // `0x00`, which does not end in "ab". + assert!(!matcher.matches(&[0, ESCAPE_CODE, 0])); + // Same run, one escape longer: "ab" + literal `0xFF` + symbol "ab". + assert!(matcher.matches(&[0, ESCAPE_CODE, ESCAPE_CODE, 0])); + // A dangling escape marker is a truncated stream, not a match. + assert!(!matcher.matches(&[0, ESCAPE_CODE])); + // Runs longer than two: a scan that only looked back one or two bytes would read the + // trailing 0 as symbol "ab" here and match. Decoded: `0xFF` then `0x00`. + assert!(!matcher.matches(&[ESCAPE_CODE, ESCAPE_CODE, ESCAPE_CODE, 0])); + // Four escapes pair off into two literal `0xFF`s, so the 0 is symbol "ab" again. + assert!(matcher.matches(&[ESCAPE_CODE, ESCAPE_CODE, ESCAPE_CODE, ESCAPE_CODE, 0])); + // A truncated stream found *after* a token has already matched: the walk still owes a + // byte and the only thing left is a dangling marker. + assert!(!matcher.matches(&[ESCAPE_CODE, ESCAPE_CODE, ESCAPE_CODE, b'b'])); + + Ok(()) +} + +/// A literal `0xFF` compared against the suffix itself, not merely skipped over. The +/// kernel accepts binary patterns, so a suffix can contain `0xFF`. +#[test] +fn test_suffix_matcher_literal_escape_byte_in_suffix() -> VortexResult<()> { + let matcher = SuffixMatcher::new(&[sym(b"ab")], &[2u8], &[b'a', ESCAPE_CODE])?; + + // Symbol "ab" then escaped `0xFF` → "ab\xFF", whose last two bytes are 'b', 0xFF. + assert!(!matcher.matches(&[0, ESCAPE_CODE, ESCAPE_CODE])); + // Escaped 'a' then escaped `0xFF` → "a\xFF". + assert!(matcher.matches(&[ESCAPE_CODE, b'a', ESCAPE_CODE, ESCAPE_CODE])); + // Escaped `0xFF` alone is one byte short of the suffix. + assert!(!matcher.matches(&[ESCAPE_CODE, ESCAPE_CODE])); + + Ok(()) +} + +/// A final symbol longer than the suffix must be compared on its *tail*. Aligning on its +/// head instead would accept `"...abcx" LIKE '%abc'`. +#[test] +fn test_suffix_matcher_symbol_longer_than_suffix() -> VortexResult<()> { + let symbols = [sym(b"abcx"), sym(b"xabc")]; + let lengths = [4u8, 4]; + let matcher = SuffixMatcher::new(&symbols, &lengths, b"abc")?; + + // "xabc" ends with the suffix; its leading 'x' is simply outside it. + assert!(matcher.matches(&[1])); + // "abcx" contains the suffix but does not end with it. + assert!(!matcher.matches(&[0])); + // Same distinction when an earlier symbol precedes it. + assert!(matcher.matches(&[0, 1])); + assert!(!matcher.matches(&[1, 0])); + + Ok(()) +} + +/// A code byte past the end of the symbol table is only producible by a corrupt file, and +/// must answer "no match" rather than panic — the prefix and contains DFAs absorb the same +/// byte in their table padding. +#[test] +fn test_suffix_matcher_code_beyond_symbol_table() -> VortexResult<()> { + let matcher = SuffixMatcher::new(&[sym(b"ab")], &[2u8], b"ab")?; + + assert!(!matcher.matches(&[5])); + assert!(!matcher.matches(&[ESCAPE_CODE, ESCAPE_CODE, 5])); + // Reachable past the first token too, where the walk still owes suffix bytes. + assert!(!matcher.matches(&[5, ESCAPE_CODE, b'b'])); + // The largest code that is not `ESCAPE_CODE`. + assert!(!matcher.matches(&[254])); + + Ok(()) +} + +#[test] +fn test_suffix_pushdown_len_254_with_escapes() { + // Heterogeneous, so that every step of the 254-byte walk reads a different byte and an + // off-by-one in the suffix index cannot pass. + let suffix: String = (0..SuffixMatcher::MAX_SUFFIX_LEN) + .map(|i| char::from(b'a' + u8::try_from(i % 26).unwrap())) + .collect(); + let pattern = format!("%{suffix}"); + let symbols: Vec = vec![]; + let matcher = FsstMatcher::try_new(&symbols, &[], pattern.as_bytes()) + .unwrap() + .expect("suffix of MAX_SUFFIX_LEN should be pushed down"); + assert!(matcher.matches(&escaped(suffix.as_bytes()))); + assert!(matcher.matches(&escaped(format!("prefix{suffix}").as_bytes()))); + // Mismatch in the interior, so the walk has to run before it can fail. + let mut interior = suffix.clone().into_bytes(); + interior[SuffixMatcher::MAX_SUFFIX_LEN / 2] = b'!'; + assert!(!matcher.matches(&escaped(&interior))); + // Mismatch at the far end of the walk. + let mut first = suffix.clone().into_bytes(); + first[0] = b'!'; + assert!(!matcher.matches(&escaped(&first))); + assert!(!matcher.matches(&escaped(format!("{suffix}b").as_bytes()))); +} + +#[test] +fn test_suffix_pushdown_rejects_len_255() { + let suffix = "a".repeat(SuffixMatcher::MAX_SUFFIX_LEN + 1); + let pattern = format!("%{suffix}"); + let symbols: Vec = vec![]; + assert!( + FsstMatcher::try_new(&symbols, &[], pattern.as_bytes()) + .unwrap() + .is_none() + ); +} + #[test] fn test_contains_pushdown_rejects_len_255() { let needle = "a".repeat(FlatContainsDfa::MAX_NEEDLE_LEN + 1); @@ -277,11 +486,30 @@ fn make_fsst_str(strings: &[Option<&str>]) -> FSSTArray { fsst_compress(&array, &compressor, &mut ctx).unwrap() } +/// Evaluates LIKE over an FSST array, asserting first that the kernel pushed the pattern +/// down. +/// +/// Without that assertion these cases prove nothing about this module: the fallback +/// decompresses and returns the same booleans, so every one of them would still pass with +/// pushdown disabled. Every pattern in the table below is a shape the module claims to +/// handle, so `None` here is a regression whichever shape it is. fn run_like(array: FSSTArray, pattern_arr: ArrayRef) -> VortexResult { + let mut ctx = SESSION.create_execution_ctx(); + assert!( + ::like( + array.as_view(), + &pattern_arr, + LikeOptions::default(), + &mut ctx + )? + .is_some(), + "pattern should be pushed down, not evaluated by decompressing" + ); + let arr: ArrayRef = array.into_array(); let result = Like::try_new(arr, pattern_arr, LikeOptions::default())? .into_array() - .execute::(&mut SESSION.create_execution_ctx())?; + .execute::(&mut ctx)?; Ok(result.into_bool()) } @@ -345,6 +573,22 @@ fn run_like(array: FSSTArray, pattern_arr: ArrayRef) -> VortexResult "%abcabcabc%", &[true, true, false, false] )] +// ---- suffix (`%suffix`) end-to-end ---- +#[case(&["abc", "xabc", "abcx", "bc", ""], "%abc", &[true, true, false, false, false])] +#[case(&[""], "%a", &[false])] +// The needle occurs, but not at the end. +#[case(&["abcabc", "abcabcx"], "%abc", &[true, false])] +// Repeated bytes: the walk owes two steps of the same byte value. +#[case(&["aa", "aaa", "aab", "a"], "%aa", &[true, true, false, false])] +// Suffix equal to the whole string, and one byte longer than it. +#[case(&["hello", "hell"], "%hello", &[true, false])] +// Overlapping suffix, where a shorter tail of it also occurs earlier. +#[case(&["abab", "ababab", "ababa", "xabab"], "%abab", &[true, true, false, true])] +// Multi-byte UTF-8 tails. +#[case(&["café latte", "café", "caf"], "%café", &[false, true, false])] +#[case(&["日本語テスト", "テスト", "テストx"], "%テスト", &[true, true, false])] +// An escaped literal `%` as the suffix — previously fell back, now pushed down. +#[case(&["100%", "100", "%100"], r"%\%", &[true, false, false])] fn test_like_edge_cases( #[case] strings: &[&str], #[case] pattern: &str,