1use sha2::{Digest, Sha256};
4
5pub const MAX_LONG_PATTERN_SOURCE_BYTES_V1: usize = 8_000;
7pub const MAX_LONG_PATTERN_MATCHED_BYTES_V1: usize = 144_000;
9pub const MAX_LONG_PATTERN_QUERY_BYTES_V1: usize = 8_000;
11pub const MAX_LONG_PATTERN_ATOMS_V1: usize = 144_000;
13pub const MAX_LONG_PATTERN_ROWS_PER_PARTITION_V1: u32 = 65_535;
15
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18pub struct LongPatternBoundsV1 {
19 source_bytes: u32,
20 matched_bytes: u32,
21 rows: u32,
22 total_matched_bytes: u64,
23 distinct_grams: u32,
24 postings: u64,
25 postings_bytes: u64,
26 grams_per_row: u32,
27 pattern_bytes: u32,
28 wildcard_atoms: u32,
29 literal_runs: u32,
30 candidates: u32,
31 verification_bytes: u64,
32 results: u32,
33}
34
35impl LongPatternBoundsV1 {
36 #[allow(clippy::too_many_arguments)]
38 pub fn new(
39 source_bytes: u32,
40 matched_bytes: u32,
41 rows: u32,
42 total_matched_bytes: u64,
43 distinct_grams: u32,
44 postings: u64,
45 postings_bytes: u64,
46 grams_per_row: u32,
47 pattern_bytes: u32,
48 wildcard_atoms: u32,
49 literal_runs: u32,
50 candidates: u32,
51 verification_bytes: u64,
52 results: u32,
53 ) -> Result<Self, LongPatternBoundsErrorV1> {
54 let bounds = Self {
55 source_bytes,
56 matched_bytes,
57 rows,
58 total_matched_bytes,
59 distinct_grams,
60 postings,
61 postings_bytes,
62 grams_per_row,
63 pattern_bytes,
64 wildcard_atoms,
65 literal_runs,
66 candidates,
67 verification_bytes,
68 results,
69 };
70 if source_bytes == 0
71 || source_bytes as usize > MAX_LONG_PATTERN_SOURCE_BYTES_V1
72 || matched_bytes == 0
73 || matched_bytes as usize > MAX_LONG_PATTERN_MATCHED_BYTES_V1
74 || rows == 0
75 || rows > MAX_LONG_PATTERN_ROWS_PER_PARTITION_V1
76 || total_matched_bytes == 0
77 || distinct_grams == 0
78 || postings == 0
79 || postings_bytes == 0
80 || grams_per_row == 0
81 || pattern_bytes == 0
82 || pattern_bytes as usize > MAX_LONG_PATTERN_QUERY_BYTES_V1
83 || wildcard_atoms == 0
84 || wildcard_atoms as usize > MAX_LONG_PATTERN_ATOMS_V1
85 || literal_runs == 0
86 || candidates == 0
87 || candidates > rows
88 || verification_bytes == 0
89 || results == 0
90 || results > candidates
91 {
92 return Err(LongPatternBoundsErrorV1);
93 }
94 Ok(bounds)
95 }
96 #[must_use]
98 pub const fn source_bytes(self) -> u32 {
99 self.source_bytes
100 }
101 #[must_use]
103 pub const fn matched_bytes(self) -> u32 {
104 self.matched_bytes
105 }
106 #[must_use]
108 pub const fn rows(self) -> u32 {
109 self.rows
110 }
111 #[must_use]
113 pub const fn total_matched_bytes(self) -> u64 {
114 self.total_matched_bytes
115 }
116 #[must_use]
118 pub const fn distinct_grams(self) -> u32 {
119 self.distinct_grams
120 }
121 #[must_use]
123 pub const fn postings(self) -> u64 {
124 self.postings
125 }
126 #[must_use]
128 pub const fn postings_bytes(self) -> u64 {
129 self.postings_bytes
130 }
131 #[must_use]
133 pub const fn grams_per_row(self) -> u32 {
134 self.grams_per_row
135 }
136 #[must_use]
138 pub const fn pattern_bytes(self) -> u32 {
139 self.pattern_bytes
140 }
141 #[must_use]
143 pub const fn wildcard_atoms(self) -> u32 {
144 self.wildcard_atoms
145 }
146 #[must_use]
148 pub const fn literal_runs(self) -> u32 {
149 self.literal_runs
150 }
151 #[must_use]
153 pub const fn candidates(self) -> u32 {
154 self.candidates
155 }
156 #[must_use]
158 pub const fn verification_bytes(self) -> u64 {
159 self.verification_bytes
160 }
161 #[must_use]
163 pub const fn results(self) -> u32 {
164 self.results
165 }
166}
167
168#[derive(Clone, Copy, Debug, Eq, PartialEq)]
170pub struct LongPatternBoundsErrorV1;
171
172#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
174#[repr(u8)]
175pub enum LongPatternProfileV1 {
176 BinaryUtf8V1 = 1,
178 UnicodeFoldV1 = 2,
180}
181
182impl LongPatternProfileV1 {
183 #[must_use]
185 pub const fn from_discriminant(value: u8) -> Option<Self> {
186 match value {
187 1 => Some(Self::BinaryUtf8V1),
188 2 => Some(Self::UnicodeFoldV1),
189 _ => None,
190 }
191 }
192
193 #[must_use]
195 pub fn matched_form(self, value: &str) -> String {
196 match self {
197 Self::BinaryUtf8V1 => value.to_owned(),
198 Self::UnicodeFoldV1 => crate::unicode_fold_v1(value),
199 }
200 }
201}
202
203#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
205#[repr(u8)]
206pub enum LongPatternOperatorV1 {
207 Equals = 1,
209 StartsWith = 2,
211 EndsWith = 3,
213 Contains = 4,
215 Like = 5,
217 ILike = 6,
219 NotLike = 7,
221 NotILike = 8,
223}
224
225impl LongPatternOperatorV1 {
226 #[must_use]
228 pub const fn from_discriminant(value: u8) -> Option<Self> {
229 match value {
230 1 => Some(Self::Equals),
231 2 => Some(Self::StartsWith),
232 3 => Some(Self::EndsWith),
233 4 => Some(Self::Contains),
234 5 => Some(Self::Like),
235 6 => Some(Self::ILike),
236 7 => Some(Self::NotLike),
237 8 => Some(Self::NotILike),
238 _ => None,
239 }
240 }
241
242 #[must_use]
244 pub const fn is_wildcard(self) -> bool {
245 matches!(
246 self,
247 Self::Like | Self::ILike | Self::NotLike | Self::NotILike
248 )
249 }
250
251 #[must_use]
253 pub const fn is_case_insensitive(self) -> bool {
254 matches!(self, Self::ILike | Self::NotILike)
255 }
256
257 #[must_use]
259 pub const fn is_negated(self) -> bool {
260 matches!(self, Self::NotLike | Self::NotILike)
261 }
262}
263
264#[derive(Clone, Copy, Debug, Eq, PartialEq)]
266enum PatternAtomV1 {
267 Literal(char),
268 One,
269 Many,
270}
271
272#[derive(Clone, Debug, Eq, PartialEq)]
274pub struct CompiledLongPatternV1 {
275 operator: LongPatternOperatorV1,
276 profile: LongPatternProfileV1,
277 literal: Option<String>,
278 atoms: Vec<PatternAtomV1>,
279 mandatory_grams: Vec<[u8; 3]>,
280}
281
282impl CompiledLongPatternV1 {
283 pub fn compile(
285 operator: LongPatternOperatorV1,
286 declared_profile: LongPatternProfileV1,
287 source: &str,
288 ) -> Result<Self, LongPatternErrorV1> {
289 Self::compile_with_limits(
290 operator,
291 declared_profile,
292 source,
293 MAX_LONG_PATTERN_QUERY_BYTES_V1,
294 MAX_LONG_PATTERN_MATCHED_BYTES_V1,
295 MAX_LONG_PATTERN_ATOMS_V1,
296 MAX_LONG_PATTERN_ATOMS_V1,
297 )
298 }
299
300 pub fn compile_bounded(
302 operator: LongPatternOperatorV1,
303 declared_profile: LongPatternProfileV1,
304 source: &str,
305 bounds: LongPatternBoundsV1,
306 ) -> Result<Self, LongPatternErrorV1> {
307 Self::compile_with_limits(
308 operator,
309 declared_profile,
310 source,
311 bounds.pattern_bytes() as usize,
312 bounds.matched_bytes() as usize,
313 bounds.wildcard_atoms() as usize,
314 bounds.literal_runs() as usize,
315 )
316 }
317
318 fn compile_with_limits(
319 operator: LongPatternOperatorV1,
320 declared_profile: LongPatternProfileV1,
321 source: &str,
322 pattern_bytes: usize,
323 matched_bytes: usize,
324 wildcard_atoms: usize,
325 literal_runs: usize,
326 ) -> Result<Self, LongPatternErrorV1> {
327 if source.is_empty() || source.len() > pattern_bytes {
328 return Err(LongPatternErrorV1::PatternBytes);
329 }
330 let profile = if operator.is_case_insensitive() {
331 LongPatternProfileV1::UnicodeFoldV1
332 } else {
333 declared_profile
334 };
335 if !operator.is_wildcard() {
336 let literal = profile.matched_form(source);
337 if literal.len() > matched_bytes {
338 return Err(LongPatternErrorV1::MatchedBytes);
339 }
340 let mandatory_grams = distinct_grams(literal.as_bytes());
341 return Ok(Self {
342 operator,
343 profile,
344 literal: Some(literal),
345 atoms: Vec::new(),
346 mandatory_grams,
347 });
348 }
349
350 let runs = parse_wildcard_runs(source)?;
351 if runs.len() > literal_runs {
352 return Err(LongPatternErrorV1::PatternAtoms);
353 }
354 let mut atoms = Vec::new();
355 let mut mandatory_grams = Vec::new();
356 for run in runs {
357 match run {
358 PatternRunV1::Literal(value) => {
359 let matched = profile.matched_form(&value);
360 mandatory_grams.extend(distinct_grams(matched.as_bytes()));
361 atoms.extend(matched.chars().map(PatternAtomV1::Literal));
362 }
363 PatternRunV1::One => atoms.push(PatternAtomV1::One),
364 PatternRunV1::Many => {
365 if !matches!(atoms.last(), Some(PatternAtomV1::Many)) {
366 atoms.push(PatternAtomV1::Many);
367 }
368 }
369 }
370 if atoms.len() > wildcard_atoms {
371 return Err(LongPatternErrorV1::PatternAtoms);
372 }
373 }
374 mandatory_grams.sort_unstable();
375 mandatory_grams.dedup();
376 Ok(Self {
377 operator,
378 profile,
379 literal: None,
380 atoms,
381 mandatory_grams,
382 })
383 }
384
385 #[must_use]
387 pub const fn operator(&self) -> LongPatternOperatorV1 {
388 self.operator
389 }
390
391 #[must_use]
393 pub const fn profile(&self) -> LongPatternProfileV1 {
394 self.profile
395 }
396
397 #[must_use]
399 pub fn mandatory_grams(&self) -> &[[u8; 3]] {
400 &self.mandatory_grams
401 }
402
403 #[must_use]
405 pub fn matches_matched(&self, matched: &str) -> bool {
406 let positive = match (self.operator, self.literal.as_deref()) {
407 (LongPatternOperatorV1::Equals, Some(literal)) => matched == literal,
408 (LongPatternOperatorV1::StartsWith, Some(literal)) => matched.starts_with(literal),
409 (LongPatternOperatorV1::EndsWith, Some(literal)) => matched.ends_with(literal),
410 (LongPatternOperatorV1::Contains, Some(literal)) => matched.contains(literal),
411 _ => wildcard_matches(&self.atoms, matched),
412 };
413 if self.operator.is_negated() {
414 !positive
415 } else {
416 positive
417 }
418 }
419
420 #[must_use]
422 pub fn matches_source(&self, source: &str) -> bool {
423 self.matches_matched(&self.profile.matched_form(source))
424 }
425}
426
427#[derive(Clone, Debug, Eq, PartialEq)]
428enum PatternRunV1 {
429 Literal(String),
430 One,
431 Many,
432}
433
434fn parse_wildcard_runs(source: &str) -> Result<Vec<PatternRunV1>, LongPatternErrorV1> {
435 let mut runs = Vec::new();
436 let mut literal = String::new();
437 let mut chars = source.chars();
438 while let Some(character) = chars.next() {
439 match character {
440 '\\' => {
441 let escaped = chars.next().ok_or(LongPatternErrorV1::InvalidEscape)?;
442 if !matches!(escaped, '%' | '_' | '\\') {
443 return Err(LongPatternErrorV1::InvalidEscape);
444 }
445 literal.push(escaped);
446 }
447 '%' | '_' => {
448 if !literal.is_empty() {
449 runs.push(PatternRunV1::Literal(std::mem::take(&mut literal)));
450 }
451 runs.push(if character == '%' {
452 PatternRunV1::Many
453 } else {
454 PatternRunV1::One
455 });
456 }
457 _ => literal.push(character),
458 }
459 }
460 if !literal.is_empty() {
461 runs.push(PatternRunV1::Literal(literal));
462 }
463 Ok(runs)
464}
465
466fn wildcard_matches(atoms: &[PatternAtomV1], matched: &str) -> bool {
467 let value = matched.chars().collect::<Vec<_>>();
468 let (mut atom, mut scalar) = (0_usize, 0_usize);
469 let (mut star, mut star_scalar) = (None, 0_usize);
470 while scalar < value.len() {
471 match atoms.get(atom) {
472 Some(PatternAtomV1::Literal(expected)) if *expected == value[scalar] => {
473 atom += 1;
474 scalar += 1;
475 }
476 Some(PatternAtomV1::One) => {
477 atom += 1;
478 scalar += 1;
479 }
480 Some(PatternAtomV1::Many) => {
481 star = Some(atom);
482 atom += 1;
483 star_scalar = scalar;
484 }
485 _ => {
486 let Some(star_atom) = star else {
487 return false;
488 };
489 star_scalar += 1;
490 scalar = star_scalar;
491 atom = star_atom + 1;
492 }
493 }
494 }
495 while matches!(atoms.get(atom), Some(PatternAtomV1::Many)) {
496 atom += 1;
497 }
498 atom == atoms.len()
499}
500
501#[must_use]
503pub fn long_pattern_grams_v1(matched: &str) -> Vec<[u8; 3]> {
504 distinct_grams(matched.as_bytes())
505}
506
507fn distinct_grams(bytes: &[u8]) -> Vec<[u8; 3]> {
508 let mut grams = bytes
509 .windows(3)
510 .map(|window| [window[0], window[1], window[2]])
511 .collect::<Vec<_>>();
512 grams.sort_unstable();
513 grams.dedup();
514 grams
515}
516
517#[must_use]
519pub fn long_pattern_digest_v1(matched: &str) -> [u8; 32] {
520 let mut digest = Sha256::new();
521 digest.update(b"RIFFDB-LONG-PATTERN-VALUE-V1\0");
522 digest.update((matched.len() as u64).to_be_bytes());
523 digest.update(matched.as_bytes());
524 digest.finalize().into()
525}
526
527#[derive(Clone, Copy, Debug, Eq, PartialEq)]
529pub enum LongPatternErrorV1 {
530 PatternBytes,
532 MatchedBytes,
534 PatternAtoms,
536 InvalidEscape,
538}
539
540crate::inspect_owned_capacity_copy_v1!(LongPatternOperatorV1);
542
543crate::inspect_owned_capacity_copy_v1!(LongPatternProfileV1);
544
545crate::inspect_owned_capacity_copy_v1!(LongPatternBoundsV1);
546
547#[cfg(test)]
548mod tests {
549 use super::*;
550
551 #[test]
552 fn wildcard_semantics_cover_scalars_escaping_folding_and_negation() {
553 let like = CompiledLongPatternV1::compile(
554 LongPatternOperatorV1::Like,
555 LongPatternProfileV1::BinaryUtf8V1,
556 r"ab%_\%",
557 )
558 .expect("pattern");
559 assert!(like.matches_source("abλ-tail%"));
560 assert!(!like.matches_source("ab%"));
561
562 let ilike = CompiledLongPatternV1::compile(
563 LongPatternOperatorV1::ILike,
564 LongPatternProfileV1::BinaryUtf8V1,
565 "strasse%",
566 )
567 .expect("folded pattern");
568 assert!(ilike.matches_source("STRAßE-tail"));
569
570 let not_like = CompiledLongPatternV1::compile(
571 LongPatternOperatorV1::NotLike,
572 LongPatternProfileV1::BinaryUtf8V1,
573 "%secret%",
574 )
575 .expect("negated pattern");
576 assert!(not_like.matches_source("public"));
577 assert!(!not_like.matches_source("a secret value"));
578 }
579
580 #[test]
581 fn invalid_escapes_and_pattern_bytes_fail_closed() {
582 for pattern in [r"trailing\", r"bad\x"] {
583 assert_eq!(
584 CompiledLongPatternV1::compile(
585 LongPatternOperatorV1::Like,
586 LongPatternProfileV1::BinaryUtf8V1,
587 pattern,
588 ),
589 Err(LongPatternErrorV1::InvalidEscape)
590 );
591 }
592 assert_eq!(
593 CompiledLongPatternV1::compile(
594 LongPatternOperatorV1::Contains,
595 LongPatternProfileV1::BinaryUtf8V1,
596 "",
597 ),
598 Err(LongPatternErrorV1::PatternBytes)
599 );
600 }
601
602 #[test]
603 fn grams_are_distinct_ordered_and_only_acceleration_hints() {
604 assert_eq!(long_pattern_grams_v1("ababa"), vec![*b"aba", *b"bab"]);
605 let wildcard = CompiledLongPatternV1::compile(
606 LongPatternOperatorV1::Like,
607 LongPatternProfileV1::BinaryUtf8V1,
608 "%ab%",
609 )
610 .expect("short literal");
611 assert!(wildcard.mandatory_grams().is_empty());
612 assert!(wildcard.matches_source("zzabzz"));
613 }
614}