1use std::collections::BTreeMap;
16
17use common_base::term_token::extract_like_probes;
18use datafusion_common::ScalarValue;
19use datafusion_expr::expr::{Like, ScalarFunction};
20use datafusion_expr::{BinaryExpr, Expr, Operator};
21use datatypes::schema::FulltextBackend;
22use object_store::ObjectStore;
23use puffin::puffin_manager::cache::PuffinMetadataCacheRef;
24use store_api::metadata::RegionMetadata;
25use store_api::region_request::PathType;
26use store_api::storage::{ColumnId, ConcreteDataType};
27
28use crate::cache::file_cache::FileCacheRef;
29use crate::cache::index::bloom_filter_index::BloomFilterIndexCacheRef;
30use crate::error::Result;
31use crate::sst::index::fulltext_index::applier::FulltextIndexApplier;
32use crate::sst::index::puffin_manager::PuffinManagerFactory;
33
34#[derive(Default, Debug, Clone, PartialEq, Eq, Hash)]
38pub struct FulltextRequest {
39 pub queries: Vec<FulltextQuery>,
40 pub terms: Vec<FulltextTerm>,
41 pub likes: Vec<FulltextLike>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Hash)]
48pub struct FulltextQuery(pub String);
49
50#[derive(Debug, Clone, PartialEq, Eq, Hash)]
55pub struct FulltextTerm {
56 pub col_lowered: bool,
57 pub term: String,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Hash)]
65pub struct FulltextLike {
66 pub col_lowered: bool,
67 pub pattern: String,
68}
69
70pub struct FulltextIndexApplierBuilder<'a> {
72 table_dir: String,
73 path_type: PathType,
74 store: ObjectStore,
75 puffin_manager_factory: PuffinManagerFactory,
76 metadata: &'a RegionMetadata,
77 file_cache: Option<FileCacheRef>,
78 puffin_metadata_cache: Option<PuffinMetadataCacheRef>,
79 bloom_filter_cache: Option<BloomFilterIndexCacheRef>,
80}
81
82impl<'a> FulltextIndexApplierBuilder<'a> {
83 pub fn new(
85 table_dir: String,
86 path_type: PathType,
87 store: ObjectStore,
88 puffin_manager_factory: PuffinManagerFactory,
89 metadata: &'a RegionMetadata,
90 ) -> Self {
91 Self {
92 table_dir,
93 path_type,
94 store,
95 puffin_manager_factory,
96 metadata,
97 file_cache: None,
98 puffin_metadata_cache: None,
99 bloom_filter_cache: None,
100 }
101 }
102
103 pub fn with_file_cache(mut self, file_cache: Option<FileCacheRef>) -> Self {
105 self.file_cache = file_cache;
106 self
107 }
108
109 pub fn with_puffin_metadata_cache(
111 mut self,
112 puffin_metadata_cache: Option<PuffinMetadataCacheRef>,
113 ) -> Self {
114 self.puffin_metadata_cache = puffin_metadata_cache;
115 self
116 }
117
118 pub fn with_bloom_filter_cache(
120 mut self,
121 bloom_filter_cache: Option<BloomFilterIndexCacheRef>,
122 ) -> Self {
123 self.bloom_filter_cache = bloom_filter_cache;
124 self
125 }
126
127 pub fn build(self, exprs: &[Expr]) -> Result<Option<FulltextIndexApplier>> {
129 let mut requests = BTreeMap::new();
130 for expr in exprs {
131 Self::extract_requests(expr, self.metadata, &mut requests);
132 }
133
134 let has_requests = requests.iter().any(|(_, request)| {
136 !request.queries.is_empty() || !request.terms.is_empty() || !request.likes.is_empty()
137 });
138
139 Ok(has_requests.then(|| {
140 FulltextIndexApplier::new(
141 self.table_dir,
142 self.path_type,
143 self.store,
144 requests,
145 self.puffin_manager_factory,
146 )
147 .with_file_cache(self.file_cache)
148 .with_puffin_metadata_cache(self.puffin_metadata_cache)
149 .with_bloom_filter_cache(self.bloom_filter_cache)
150 }))
151 }
152
153 fn extract_requests(
154 expr: &Expr,
155 metadata: &'a RegionMetadata,
156 requests: &mut BTreeMap<ColumnId, FulltextRequest>,
157 ) {
158 match expr {
159 Expr::BinaryExpr(BinaryExpr {
160 left,
161 op: Operator::And,
162 right,
163 }) => {
164 Self::extract_requests(left, metadata, requests);
165 Self::extract_requests(right, metadata, requests);
166 }
167 Expr::Like(like) => {
168 if let Some((column_id, like)) = Self::expr_to_like(metadata, like) {
169 requests.entry(column_id).or_default().likes.push(like);
170 }
171 }
172 Expr::ScalarFunction(func) => {
173 if let Some((column_id, query)) = Self::expr_to_query(metadata, func) {
174 requests.entry(column_id).or_default().queries.push(query);
175 } else if let Some((column_id, term)) = Self::expr_to_term(metadata, func) {
176 requests.entry(column_id).or_default().terms.push(term);
177 }
178 }
179 _ => {}
180 }
181 }
182
183 fn expr_to_query(
184 metadata: &RegionMetadata,
185 f: &ScalarFunction,
186 ) -> Option<(ColumnId, FulltextQuery)> {
187 if f.name() != "matches" {
188 return None;
189 }
190 if f.args.len() != 2 {
191 return None;
192 }
193
194 let Expr::Column(c) = &f.args[0] else {
195 return None;
196 };
197 let column = metadata.column_by_name(&c.name)?;
198
199 if column.column_schema.data_type != ConcreteDataType::string_datatype() {
200 return None;
201 }
202
203 let Expr::Literal(ScalarValue::Utf8(Some(query)), _) = &f.args[1] else {
204 return None;
205 };
206
207 Some((column.column_id, FulltextQuery(query.clone())))
208 }
209
210 fn expr_to_term(
211 metadata: &RegionMetadata,
212 f: &ScalarFunction,
213 ) -> Option<(ColumnId, FulltextTerm)> {
214 if f.name() != "matches_term" {
215 return None;
216 }
217 if f.args.len() != 2 {
218 return None;
219 }
220
221 let mut lowered = false;
222 let column;
223 match &f.args[0] {
224 Expr::Column(c) => {
225 column = c;
226 }
227 Expr::ScalarFunction(f) => {
228 let lower_arg = Self::extract_lower_arg(f)?;
229 lowered = true;
230 if let Expr::Column(c) = lower_arg {
231 column = c;
232 } else {
233 return None;
234 }
235 }
236 _ => return None,
237 }
238
239 let column = metadata.column_by_name(&column.name)?;
240 if column.column_schema.data_type != ConcreteDataType::string_datatype() {
241 return None;
242 }
243
244 let Expr::Literal(ScalarValue::Utf8(Some(term)), _) = &f.args[1] else {
245 return None;
246 };
247
248 Some((
249 column.column_id,
250 FulltextTerm {
251 col_lowered: lowered,
252 term: term.clone(),
253 },
254 ))
255 }
256
257 fn expr_to_like(metadata: &RegionMetadata, like: &Like) -> Option<(ColumnId, FulltextLike)> {
258 if like.negated || like.case_insensitive {
261 return None;
262 }
263 if !matches!(like.escape_char, None | Some('\\')) {
265 return None;
266 }
267
268 let (c, col_lowered) = match like.expr.as_ref() {
269 Expr::Column(c) => (c, false),
270 Expr::ScalarFunction(f) => match Self::extract_lower_arg(f)? {
271 Expr::Column(c) => (c, true),
272 _ => return None,
273 },
274 _ => return None,
275 };
276 let column = metadata.column_by_name(&c.name)?;
277 if column.column_schema.data_type != ConcreteDataType::string_datatype() {
278 return None;
279 }
280 let options = column.column_schema.fulltext_options().ok()??;
283 if !options.enable || options.backend != FulltextBackend::Bloom {
284 return None;
285 }
286
287 let Expr::Literal(
288 ScalarValue::Utf8(Some(pattern))
289 | ScalarValue::LargeUtf8(Some(pattern))
290 | ScalarValue::Utf8View(Some(pattern)),
291 _,
292 ) = like.pattern.as_ref()
293 else {
294 return None;
295 };
296 if extract_like_probes(pattern).is_empty() {
297 return None;
298 }
299
300 Some((
301 column.column_id,
302 FulltextLike {
303 col_lowered,
304 pattern: pattern.clone(),
305 },
306 ))
307 }
308
309 fn extract_lower_arg(lower_func: &ScalarFunction) -> Option<&Expr> {
310 if lower_func.args.len() != 1 {
311 return None;
312 }
313
314 if lower_func.name() != "lower" {
315 return None;
316 }
317
318 if lower_func.args.len() != 1 {
319 return None;
320 }
321
322 Some(&lower_func.args[0])
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use std::sync::Arc;
329
330 use api::v1::SemanticType;
331 use common_function::function::FunctionRef;
332 use common_function::function_factory::ScalarFunctionFactory;
333 use common_function::scalars::matches::MatchesFunction;
334 use common_function::scalars::matches_term::MatchesTermFunction;
335 use datafusion::functions::string::lower;
336 use datafusion_common::Column;
337 use datafusion_expr::expr::{Like, ScalarFunction};
338 use datafusion_expr::{Literal, ScalarUDF};
339 use datatypes::schema::{ColumnSchema, FulltextAnalyzer, FulltextOptions};
340 use store_api::metadata::{ColumnMetadata, RegionMetadataBuilder};
341 use store_api::storage::RegionId;
342
343 use super::*;
344
345 fn mock_metadata() -> RegionMetadata {
346 let mut builder = RegionMetadataBuilder::new(RegionId::new(1, 2));
347 builder
348 .push_column_metadata(ColumnMetadata {
349 column_schema: ColumnSchema::new("text", ConcreteDataType::string_datatype(), true),
350 semantic_type: SemanticType::Field,
351 column_id: 1,
352 })
353 .push_column_metadata(ColumnMetadata {
354 column_schema: ColumnSchema::new(
355 "ts",
356 ConcreteDataType::timestamp_millisecond_datatype(),
357 false,
358 ),
359 semantic_type: SemanticType::Timestamp,
360 column_id: 2,
361 });
362
363 builder.build().unwrap()
364 }
365
366 fn matches_func() -> Arc<ScalarUDF> {
367 Arc::new(
368 ScalarFunctionFactory::from(Arc::new(MatchesFunction::default()) as FunctionRef)
369 .provide(Default::default()),
370 )
371 }
372
373 fn matches_term_func() -> Arc<ScalarUDF> {
374 Arc::new(
375 ScalarFunctionFactory::from(Arc::new(MatchesTermFunction::default()) as FunctionRef)
376 .provide(Default::default()),
377 )
378 }
379
380 #[test]
381 fn test_expr_to_query_basic() {
382 let metadata = mock_metadata();
383
384 let func = ScalarFunction {
385 args: vec![Expr::Column(Column::from_name("text")), "foo".lit()],
386 func: matches_func(),
387 };
388
389 let (column_id, query) =
390 FulltextIndexApplierBuilder::expr_to_query(&metadata, &func).unwrap();
391 assert_eq!(column_id, 1);
392 assert_eq!(query, FulltextQuery("foo".to_string()));
393 }
394
395 #[test]
396 fn test_expr_to_query_wrong_num_args() {
397 let metadata = mock_metadata();
398
399 let func = ScalarFunction {
400 args: vec![Expr::Column(Column::from_name("text"))],
401 func: matches_func(),
402 };
403
404 assert!(FulltextIndexApplierBuilder::expr_to_query(&metadata, &func).is_none());
405 }
406
407 #[test]
408 fn test_expr_to_query_not_found_column() {
409 let metadata = mock_metadata();
410
411 let func = ScalarFunction {
412 args: vec![Expr::Column(Column::from_name("not_found")), "foo".lit()],
413 func: matches_func(),
414 };
415
416 assert!(FulltextIndexApplierBuilder::expr_to_query(&metadata, &func).is_none());
417 }
418
419 #[test]
420 fn test_expr_to_query_column_wrong_data_type() {
421 let metadata = mock_metadata();
422
423 let func = ScalarFunction {
424 args: vec![Expr::Column(Column::from_name("ts")), "foo".lit()],
425 func: matches_func(),
426 };
427
428 assert!(FulltextIndexApplierBuilder::expr_to_query(&metadata, &func).is_none());
429 }
430
431 #[test]
432 fn test_expr_to_query_pattern_not_string() {
433 let metadata = mock_metadata();
434
435 let func = ScalarFunction {
436 args: vec![Expr::Column(Column::from_name("text")), 42.lit()],
437 func: matches_func(),
438 };
439
440 assert!(FulltextIndexApplierBuilder::expr_to_query(&metadata, &func).is_none());
441 }
442
443 #[test]
444 fn test_expr_to_term_basic() {
445 let metadata = mock_metadata();
446
447 let func = ScalarFunction {
448 args: vec![Expr::Column(Column::from_name("text")), "foo".lit()],
449 func: matches_term_func(),
450 };
451
452 let (column_id, term) =
453 FulltextIndexApplierBuilder::expr_to_term(&metadata, &func).unwrap();
454 assert_eq!(column_id, 1);
455 assert_eq!(
456 term,
457 FulltextTerm {
458 col_lowered: false,
459 term: "foo".to_string(),
460 }
461 );
462 }
463
464 #[test]
465 fn test_expr_to_term_with_lower() {
466 let metadata = mock_metadata();
467
468 let lower_func_expr = ScalarFunction {
469 args: vec![Expr::Column(Column::from_name("text"))],
470 func: lower(),
471 };
472
473 let func = ScalarFunction {
474 args: vec![Expr::ScalarFunction(lower_func_expr), "foo".lit()],
475 func: matches_term_func(),
476 };
477
478 let (column_id, term) =
479 FulltextIndexApplierBuilder::expr_to_term(&metadata, &func).unwrap();
480 assert_eq!(column_id, 1);
481 assert_eq!(
482 term,
483 FulltextTerm {
484 col_lowered: true,
485 term: "foo".to_string(),
486 }
487 );
488 }
489
490 #[test]
491 fn test_expr_to_term_wrong_num_args() {
492 let metadata = mock_metadata();
493
494 let func = ScalarFunction {
495 args: vec![Expr::Column(Column::from_name("text"))],
496 func: matches_term_func(),
497 };
498
499 assert!(FulltextIndexApplierBuilder::expr_to_term(&metadata, &func).is_none());
500 }
501
502 #[test]
503 fn test_expr_to_term_wrong_function_name() {
504 let metadata = mock_metadata();
505
506 let func = ScalarFunction {
507 args: vec![Expr::Column(Column::from_name("text")), "foo".lit()],
508 func: matches_func(), };
510
511 assert!(FulltextIndexApplierBuilder::expr_to_term(&metadata, &func).is_none());
512 }
513
514 #[test]
515 fn test_extract_lower_arg() {
516 let func = ScalarFunction {
517 args: vec![Expr::Column(Column::from_name("text"))],
518 func: lower(),
519 };
520
521 let arg = FulltextIndexApplierBuilder::extract_lower_arg(&func).unwrap();
522 match arg {
523 Expr::Column(c) => {
524 assert_eq!(c.name, "text");
525 }
526 _ => panic!("Expected Column expression"),
527 }
528 }
529
530 #[test]
531 fn test_extract_lower_arg_wrong_function() {
532 let func = ScalarFunction {
533 args: vec![Expr::Column(Column::from_name("text"))],
534 func: matches_func(), };
536
537 assert!(FulltextIndexApplierBuilder::extract_lower_arg(&func).is_none());
538 }
539
540 #[test]
541 fn test_extract_requests() {
542 let metadata = mock_metadata();
543
544 let matches_expr = Expr::ScalarFunction(ScalarFunction {
546 args: vec![Expr::Column(Column::from_name("text")), "foo".lit()],
547 func: matches_func(),
548 });
549
550 let mut requests = BTreeMap::new();
551 FulltextIndexApplierBuilder::extract_requests(&matches_expr, &metadata, &mut requests);
552
553 assert_eq!(requests.len(), 1);
554 let request = requests.get(&1).unwrap();
555 assert_eq!(request.queries.len(), 1);
556 assert_eq!(request.terms.len(), 0);
557 assert_eq!(request.queries[0], FulltextQuery("foo".to_string()));
558 }
559
560 fn mock_metadata_with_fulltext(backend: FulltextBackend) -> RegionMetadata {
561 let mut builder = RegionMetadataBuilder::new(RegionId::new(1, 2));
562 builder
563 .push_column_metadata(ColumnMetadata {
564 column_schema: ColumnSchema::new("text", ConcreteDataType::string_datatype(), true)
565 .with_fulltext_options(FulltextOptions::new_unchecked(
566 true,
567 FulltextAnalyzer::English,
568 false,
569 backend,
570 10240,
571 0.01,
572 ))
573 .unwrap(),
574 semantic_type: SemanticType::Field,
575 column_id: 1,
576 })
577 .push_column_metadata(ColumnMetadata {
578 column_schema: ColumnSchema::new(
579 "ts",
580 ConcreteDataType::timestamp_millisecond_datatype(),
581 false,
582 ),
583 semantic_type: SemanticType::Timestamp,
584 column_id: 2,
585 });
586
587 builder.build().unwrap()
588 }
589
590 #[test]
591 fn test_expr_to_like() {
592 let like = |negated, case_insensitive, escape_char, pattern: &str| Like {
593 negated,
594 expr: Box::new(Expr::Column(Column::from_name("text"))),
595 pattern: Box::new(pattern.lit()),
596 escape_char,
597 case_insensitive,
598 };
599 let bloom = mock_metadata_with_fulltext(FulltextBackend::Bloom);
600
601 for escape_char in [None, Some('\\')] {
602 assert_eq!(
603 FulltextIndexApplierBuilder::expr_to_like(
604 &bloom,
605 &like(false, false, escape_char, "%foo bar baz%")
606 ),
607 Some((
608 1,
609 FulltextLike {
610 col_lowered: false,
611 pattern: "%foo bar baz%".to_string(),
612 }
613 ))
614 );
615 }
616 let lowered = Like {
617 expr: Box::new(Expr::ScalarFunction(ScalarFunction {
618 args: vec![Expr::Column(Column::from_name("text"))],
619 func: lower(),
620 })),
621 ..like(false, false, None, "%foo bar baz%")
622 };
623 assert_eq!(
624 FulltextIndexApplierBuilder::expr_to_like(&bloom, &lowered),
625 Some((
626 1,
627 FulltextLike {
628 col_lowered: true,
629 pattern: "%foo bar baz%".to_string(),
630 }
631 ))
632 );
633 for like in [
636 like(true, false, None, "%foo bar baz%"),
637 like(false, true, None, "%foo bar baz%"),
638 like(false, false, Some('!'), "%foo bar baz%"),
639 like(false, false, None, "%foo%"),
640 ] {
641 assert_eq!(
642 FulltextIndexApplierBuilder::expr_to_like(&bloom, &like),
643 None
644 );
645 }
646 for metadata in [
648 mock_metadata(),
649 mock_metadata_with_fulltext(FulltextBackend::Tantivy),
650 ] {
651 assert_eq!(
652 FulltextIndexApplierBuilder::expr_to_like(
653 &metadata,
654 &like(false, false, None, "%foo bar baz%")
655 ),
656 None
657 );
658 }
659 }
660
661 #[test]
662 fn test_extract_multiple_requests() {
663 let metadata = mock_metadata();
664
665 let matches_expr = Expr::ScalarFunction(ScalarFunction {
667 args: vec![Expr::Column(Column::from_name("text")), "foo".lit()],
668 func: matches_func(),
669 });
670
671 let matches_term_expr = Expr::ScalarFunction(ScalarFunction {
673 args: vec![Expr::Column(Column::from_name("text")), "bar".lit()],
674 func: matches_term_func(),
675 });
676
677 let binary_expr = Expr::BinaryExpr(BinaryExpr {
679 left: Box::new(matches_expr),
680 op: Operator::And,
681 right: Box::new(matches_term_expr),
682 });
683
684 let mut requests = BTreeMap::new();
685 FulltextIndexApplierBuilder::extract_requests(&binary_expr, &metadata, &mut requests);
686
687 assert_eq!(requests.len(), 1);
688 let request = requests.get(&1).unwrap();
689 assert_eq!(request.queries.len(), 1);
690 assert_eq!(request.terms.len(), 1);
691 assert_eq!(request.queries[0], FulltextQuery("foo".to_string()));
692 assert_eq!(
693 request.terms[0],
694 FulltextTerm {
695 col_lowered: false,
696 term: "bar".to_string(),
697 }
698 );
699 }
700}