Skip to content

Commit 39fd9e6

Browse files
authored
fsst like to respect sql escape codes in the pattern (#8107)
## Summary fsst like dfa implementation to respect escape codes like `%` and `_` in the pattern --------- Signed-off-by: Onur Satici <onur@spiraldb.com>
1 parent e22c9dc commit 39fd9e6

3 files changed

Lines changed: 208 additions & 41 deletions

File tree

encodings/fsst/src/compute/like.rs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,95 @@ mod tests {
287287
let result = <FSST as LikeKernel>::like(fsst_v, &pattern, opts, &mut ctx)?;
288288
assert!(result.is_none(), "ilike should fall back");
289289

290+
// Suffix patterns are still unsupported, even when the suffix is an escaped literal.
291+
let pattern = ConstantArray::new(r"%\%", fsst.len()).into_array();
292+
let result =
293+
<FSST as LikeKernel>::like(fsst_v, &pattern, LikeOptions::default(), &mut ctx)?;
294+
assert!(result.is_none(), "escaped suffix pattern should fall back");
295+
296+
Ok(())
297+
}
298+
299+
#[test]
300+
fn test_like_kernel_handles_escaped_prefix_and_contains() -> VortexResult<()> {
301+
let fsst = make_fsst(
302+
&[
303+
Some("%front"),
304+
Some("_front"),
305+
Some("\\front"),
306+
Some("middle%value"),
307+
Some("middle_value"),
308+
Some("middle\\value"),
309+
Some("front"),
310+
],
311+
Nullability::NonNullable,
312+
);
313+
let fsst_v = fsst.as_view();
314+
let mut ctx = SESSION.create_execution_ctx();
315+
316+
let pattern = ConstantArray::new(r"\%%", fsst.len()).into_array();
317+
let result =
318+
<FSST as LikeKernel>::like(fsst_v, &pattern, LikeOptions::default(), &mut ctx)?;
319+
assert!(result.is_some(), "escaped percent prefix should use FSST");
320+
assert_arrays_eq!(
321+
result.unwrap(),
322+
BoolArray::from_iter([true, false, false, false, false, false, false])
323+
);
324+
325+
let pattern = ConstantArray::new(r"\_%", fsst.len()).into_array();
326+
let result =
327+
<FSST as LikeKernel>::like(fsst_v, &pattern, LikeOptions::default(), &mut ctx)?;
328+
assert!(
329+
result.is_some(),
330+
"escaped underscore prefix should use FSST"
331+
);
332+
assert_arrays_eq!(
333+
result.unwrap(),
334+
BoolArray::from_iter([false, true, false, false, false, false, false])
335+
);
336+
337+
let pattern = ConstantArray::new(r"\\%", fsst.len()).into_array();
338+
let result =
339+
<FSST as LikeKernel>::like(fsst_v, &pattern, LikeOptions::default(), &mut ctx)?;
340+
assert!(result.is_some(), "escaped backslash prefix should use FSST");
341+
assert_arrays_eq!(
342+
result.unwrap(),
343+
BoolArray::from_iter([false, false, true, false, false, false, false])
344+
);
345+
346+
let pattern = ConstantArray::new(r"%\%%", fsst.len()).into_array();
347+
let result =
348+
<FSST as LikeKernel>::like(fsst_v, &pattern, LikeOptions::default(), &mut ctx)?;
349+
assert!(result.is_some(), "escaped percent contains should use FSST");
350+
assert_arrays_eq!(
351+
result.unwrap(),
352+
BoolArray::from_iter([true, false, false, true, false, false, false])
353+
);
354+
355+
let pattern = ConstantArray::new(r"%\_%", fsst.len()).into_array();
356+
let result =
357+
<FSST as LikeKernel>::like(fsst_v, &pattern, LikeOptions::default(), &mut ctx)?;
358+
assert!(
359+
result.is_some(),
360+
"escaped underscore contains should use FSST"
361+
);
362+
assert_arrays_eq!(
363+
result.unwrap(),
364+
BoolArray::from_iter([false, true, false, false, true, false, false])
365+
);
366+
367+
let pattern = ConstantArray::new(r"%\\%", fsst.len()).into_array();
368+
let result =
369+
<FSST as LikeKernel>::like(fsst_v, &pattern, LikeOptions::default(), &mut ctx)?;
370+
assert!(
371+
result.is_some(),
372+
"escaped backslash contains should use FSST"
373+
);
374+
assert_arrays_eq!(
375+
result.unwrap(),
376+
BoolArray::from_iter([false, false, true, false, false, true, false])
377+
);
378+
290379
Ok(())
291380
}
292381

encodings/fsst/src/dfa/mod.rs

Lines changed: 65 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ mod prefix;
126126
#[cfg(test)]
127127
mod tests;
128128

129+
use std::borrow::Cow;
130+
129131
use flat_contains::FlatContainsDfa;
130132
use fsst::ESCAPE_CODE;
131133
use fsst::Symbol;
@@ -170,18 +172,28 @@ impl FsstMatcher {
170172
};
171173

172174
let inner = match like_kind {
173-
LikeKind::Prefix(b"") | LikeKind::Contains(b"") => MatcherInner::MatchAll,
175+
LikeKind::Prefix(pattern) | LikeKind::Contains(pattern) if pattern.is_empty() => {
176+
MatcherInner::MatchAll
177+
}
174178
LikeKind::Prefix(prefix) => {
175179
if prefix.len() > FlatPrefixDfa::MAX_PREFIX_LEN {
176180
return Ok(None);
177181
}
178-
MatcherInner::Prefix(FlatPrefixDfa::new(symbols, symbol_lengths, prefix)?)
182+
MatcherInner::Prefix(FlatPrefixDfa::new(
183+
symbols,
184+
symbol_lengths,
185+
prefix.as_ref(),
186+
)?)
179187
}
180188
LikeKind::Contains(needle) => {
181189
if needle.len() > FlatContainsDfa::MAX_NEEDLE_LEN {
182190
return Ok(None);
183191
}
184-
MatcherInner::Contains(FlatContainsDfa::new(symbols, symbol_lengths, needle)?)
192+
MatcherInner::Contains(FlatContainsDfa::new(
193+
symbols,
194+
symbol_lengths,
195+
needle.as_ref(),
196+
)?)
185197
}
186198
};
187199

@@ -201,34 +213,66 @@ impl FsstMatcher {
201213
/// The subset of LIKE patterns we can handle without decompression.
202214
enum LikeKind<'a> {
203215
/// `prefix%`
204-
Prefix(&'a [u8]),
216+
Prefix(Cow<'a, [u8]>),
205217
/// `%needle%`
206-
Contains(&'a [u8]),
218+
Contains(Cow<'a, [u8]>),
207219
}
208220

209221
impl<'a> LikeKind<'a> {
210222
fn parse(pattern: &'a [u8]) -> Option<Self> {
211-
// The fast-path matchers below do not understand SQL LIKE escape sequences (e.g. `\%`
212-
// matching a literal `%`). If the pattern contains a backslash we fall back to the
213-
// general implementation, which correctly interprets escapes.
214-
if pattern.contains(&b'\\') {
223+
Self::parse_prefix(pattern).or_else(|| Self::parse_contains(pattern))
224+
}
225+
226+
fn parse_prefix(pattern: &'a [u8]) -> Option<Self> {
227+
Self::parse_literal_until_final_percent(pattern, 0).map(LikeKind::Prefix)
228+
}
229+
230+
fn parse_contains(pattern: &'a [u8]) -> Option<Self> {
231+
if !pattern.starts_with(b"%") {
215232
return None;
216233
}
217234

218-
// `prefix%` (including just `%` where prefix is empty)
219-
if let Some(prefix) = pattern.strip_suffix(b"%")
220-
&& !prefix.contains(&b'%')
221-
&& !prefix.contains(&b'_')
222-
{
223-
return Some(LikeKind::Prefix(prefix));
224-
}
235+
Self::parse_literal_until_final_percent(pattern, 1).map(LikeKind::Contains)
236+
}
225237

226-
// `%needle%`
227-
let inner = pattern.strip_prefix(b"%")?.strip_suffix(b"%")?;
228-
if !inner.contains(&b'%') && !inner.contains(&b'_') {
229-
return Some(LikeKind::Contains(inner));
238+
/// Parse `pattern[literal_start..]` as a literal terminated by a single
239+
/// trailing `%`. Returns `None` if `_` or a non-final `%` is encountered.
240+
///
241+
/// `literal` stays `None` until an escape forces us to materialize bytes;
242+
/// from then on we push into the owned `Vec`. Otherwise we return a
243+
/// borrowed slice straight from `pattern`.
244+
fn parse_literal_until_final_percent(
245+
pattern: &'a [u8],
246+
literal_start: usize,
247+
) -> Option<Cow<'a, [u8]>> {
248+
let mut literal: Option<Vec<u8>> = None;
249+
let mut idx = literal_start;
250+
while idx < pattern.len() {
251+
match pattern[idx] {
252+
b'\\' => {
253+
// Trailing `\` is treated as a literal backslash.
254+
let escaped = pattern.get(idx + 1).copied().unwrap_or(b'\\');
255+
literal
256+
.get_or_insert_with(|| pattern[literal_start..idx].to_vec())
257+
.push(escaped);
258+
idx = (idx + 2).min(pattern.len());
259+
}
260+
b'%' if idx + 1 == pattern.len() => {
261+
return Some(match literal {
262+
Some(buf) => Cow::Owned(buf),
263+
None => Cow::Borrowed(&pattern[literal_start..idx]),
264+
});
265+
}
266+
b'%' | b'_' => return None,
267+
byte => {
268+
// No-op on the borrowed path; only push once we've started copying.
269+
if let Some(literal) = &mut literal {
270+
literal.push(byte);
271+
}
272+
idx += 1;
273+
}
274+
}
230275
}
231-
232276
None
233277
}
234278
}

encodings/fsst/src/dfa/tests.rs

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// SPDX-License-Identifier: Apache-2.0
22
// SPDX-FileCopyrightText: Copyright the Vortex contributors
33

4+
use std::borrow::Cow;
45
use std::sync::LazyLock;
56

67
use fsst::ESCAPE_CODE;
@@ -50,30 +51,63 @@ fn escaped(bytes: &[u8]) -> Vec<u8> {
5051
codes
5152
}
5253

54+
fn assert_borrowed_prefix(pattern: &[u8], expected: &[u8]) {
55+
let Some(LikeKind::Prefix(actual)) = LikeKind::parse(pattern) else {
56+
panic!("expected borrowed prefix pattern");
57+
};
58+
assert!(matches!(actual, Cow::Borrowed(_)));
59+
assert_eq!(actual.as_ref(), expected);
60+
}
61+
62+
fn assert_owned_prefix(pattern: &[u8], expected: &[u8]) {
63+
let Some(LikeKind::Prefix(actual)) = LikeKind::parse(pattern) else {
64+
panic!("expected owned prefix pattern");
65+
};
66+
assert!(matches!(actual, Cow::Owned(_)));
67+
assert_eq!(actual.as_ref(), expected);
68+
}
69+
70+
fn assert_borrowed_contains(pattern: &[u8], expected: &[u8]) {
71+
let Some(LikeKind::Contains(actual)) = LikeKind::parse(pattern) else {
72+
panic!("expected borrowed contains pattern");
73+
};
74+
assert!(matches!(actual, Cow::Borrowed(_)));
75+
assert_eq!(actual.as_ref(), expected);
76+
}
77+
78+
fn assert_owned_contains(pattern: &[u8], expected: &[u8]) {
79+
let Some(LikeKind::Contains(actual)) = LikeKind::parse(pattern) else {
80+
panic!("expected owned contains pattern");
81+
};
82+
assert!(matches!(actual, Cow::Owned(_)));
83+
assert_eq!(actual.as_ref(), expected);
84+
}
85+
5386
#[test]
54-
fn test_like_kind_parse() {
55-
assert!(matches!(
56-
LikeKind::parse(b"http%"),
57-
Some(LikeKind::Prefix(b"http"))
58-
));
59-
assert!(matches!(
60-
LikeKind::parse(b"%needle%"),
61-
Some(LikeKind::Contains(b"needle"))
62-
));
63-
assert!(matches!(LikeKind::parse(b"%"), Some(LikeKind::Prefix(b""))));
64-
// Suffix and underscore patterns are not supported.
87+
fn test_like_kind_parse_plain_patterns() {
88+
assert_borrowed_prefix(b"http%", b"http");
89+
assert_borrowed_contains(b"%needle%", b"needle");
90+
assert_borrowed_prefix(b"%", b"");
91+
}
92+
93+
#[test]
94+
fn test_like_kind_parse_escaped_patterns() {
95+
assert_owned_prefix(br"\%%", b"%");
96+
assert_owned_prefix(br"\_%", b"_");
97+
assert_owned_prefix(br"\\%", b"\\");
98+
assert_owned_prefix(br"has\%middle%", b"has%middle");
99+
assert_owned_contains(br"%\%%", b"%");
100+
assert_owned_contains(br"%\_%", b"_");
101+
assert_owned_contains(br"%\\%", b"\\");
102+
assert_owned_contains(br"%has\%middle%", b"has%middle");
103+
}
104+
105+
#[test]
106+
fn test_like_kind_parse_unsupported_patterns() {
65107
assert!(LikeKind::parse(b"%suffix").is_none());
66108
assert!(LikeKind::parse(b"a_c").is_none());
67-
68-
// Patterns containing the SQL LIKE escape character must not be parsed by the fast path,
69-
// because that path treats `%` and `_` literally and would misinterpret escapes. For
70-
// example, `%\%` (the pattern produced by Spark's `endsWith("%")`) means "ends with `%`",
71-
// not "contains `\`". The fast path should bail so the general implementation handles it.
72109
assert!(LikeKind::parse(br"%\%").is_none());
73-
assert!(LikeKind::parse(br"\%%").is_none());
74-
assert!(LikeKind::parse(br"%\_%").is_none());
75-
assert!(LikeKind::parse(br"\_%").is_none());
76-
assert!(LikeKind::parse(br"%\\%").is_none());
110+
assert!(LikeKind::parse(br"foo\%bar").is_none());
77111
}
78112

79113
/// No symbols — all bytes escaped. Simplest case to see the two tables.

0 commit comments

Comments
 (0)