common_function/scalars/
matches_term.rs1use std::fmt;
16use std::sync::Arc;
17
18use common_base::term_token::{CharClass, classify_char};
19use datafusion_common::arrow::array::{Array, AsArray, BooleanArray, BooleanBuilder};
20use datafusion_common::arrow::compute;
21use datafusion_common::arrow::datatypes::DataType;
22use datafusion_common::{DataFusionError, ScalarValue};
23use datafusion_expr::{ColumnarValue, ScalarFunctionArgs, Signature, Volatility};
24use memchr::memmem;
25
26use crate::function::Function;
27use crate::function_registry::FunctionRegistry;
28
29pub struct MatchesTermFunction {
79 signature: Signature,
80}
81
82impl MatchesTermFunction {
83 pub fn register(registry: &FunctionRegistry) {
84 registry.register_scalar(MatchesTermFunction::default());
85 }
86}
87
88impl Default for MatchesTermFunction {
89 fn default() -> Self {
90 Self {
91 signature: Signature::string(2, Volatility::Immutable),
92 }
93 }
94}
95
96impl fmt::Display for MatchesTermFunction {
97 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
98 write!(f, "MATCHES_TERM")
99 }
100}
101
102impl Function for MatchesTermFunction {
103 fn name(&self) -> &str {
104 "matches_term"
105 }
106
107 fn return_type(&self, _: &[DataType]) -> datafusion_common::Result<DataType> {
108 Ok(DataType::Boolean)
109 }
110
111 fn signature(&self) -> &Signature {
112 &self.signature
113 }
114
115 fn invoke_with_args(
116 &self,
117 args: ScalarFunctionArgs,
118 ) -> datafusion_common::Result<ColumnarValue> {
119 let [arg0, arg1] = datafusion_common::utils::take_function_args(self.name(), &args.args)?;
120
121 fn as_str(v: &ScalarValue) -> Option<&str> {
122 match v {
123 ScalarValue::Utf8View(Some(x))
124 | ScalarValue::Utf8(Some(x))
125 | ScalarValue::LargeUtf8(Some(x)) => Some(x.as_str()),
126 _ => None,
127 }
128 }
129
130 if let (ColumnarValue::Scalar(text), ColumnarValue::Scalar(term)) = (arg0, arg1) {
131 let text = as_str(text);
132 let term = as_str(term);
133 let result = match (text, term) {
134 (Some(text), Some(term)) => Some(MatchesTermFinder::new(term).find(text)),
135 _ => None,
136 };
137 return Ok(ColumnarValue::Scalar(ScalarValue::Boolean(result)));
138 };
139
140 let v = match (arg0, arg1) {
141 (ColumnarValue::Scalar(_), ColumnarValue::Scalar(_)) => {
142 unreachable!()
144 }
145 (ColumnarValue::Scalar(text), ColumnarValue::Array(terms)) => {
146 let text = as_str(text);
147 if let Some(text) = text {
148 let terms = compute::cast(terms, &DataType::Utf8View)?;
149 let terms = terms.as_string_view();
150
151 let mut builder = BooleanBuilder::with_capacity(terms.len());
152 terms.iter().for_each(|term| {
153 builder.append_option(term.map(|x| MatchesTermFinder::new(x).find(text)))
154 });
155 ColumnarValue::Array(Arc::new(builder.finish()))
156 } else {
157 ColumnarValue::Array(Arc::new(BooleanArray::new_null(terms.len())))
158 }
159 }
160 (ColumnarValue::Array(texts), ColumnarValue::Scalar(term)) => {
161 let term = as_str(term);
162 if let Some(term) = term {
163 let finder = MatchesTermFinder::new(term);
164
165 let texts = compute::cast(texts, &DataType::Utf8View)?;
166 let texts = texts.as_string_view();
167
168 let mut builder = BooleanBuilder::with_capacity(texts.len());
169 texts
170 .iter()
171 .for_each(|text| builder.append_option(text.map(|x| finder.find(x))));
172 ColumnarValue::Array(Arc::new(builder.finish()))
173 } else {
174 ColumnarValue::Array(Arc::new(BooleanArray::new_null(texts.len())))
175 }
176 }
177 (ColumnarValue::Array(texts), ColumnarValue::Array(terms)) => {
178 let terms = compute::cast(terms, &DataType::Utf8View)?;
179 let terms = terms.as_string_view();
180 let texts = compute::cast(texts, &DataType::Utf8View)?;
181 let texts = texts.as_string_view();
182
183 let len = texts.len();
184 if terms.len() != len {
185 return Err(DataFusionError::Internal(format!(
186 "input arrays have different lengths: {len}, {}",
187 terms.len()
188 )));
189 }
190
191 let mut builder = BooleanBuilder::with_capacity(len);
192 for (text, term) in texts.iter().zip(terms.iter()) {
193 let result = match (text, term) {
194 (Some(text), Some(term)) => Some(MatchesTermFinder::new(term).find(text)),
195 _ => None,
196 };
197 builder.append_option(result);
198 }
199 ColumnarValue::Array(Arc::new(builder.finish()))
200 }
201 };
202 Ok(v)
203 }
204}
205
206#[derive(Clone, Debug)]
225pub struct MatchesTermFinder {
226 finder: memmem::Finder<'static>,
227 term: String,
228 term_kind: TermKind,
229 starts_with_other: bool,
230 ends_with_other: bool,
231}
232
233#[derive(Clone, Copy, Debug, PartialEq, Eq)]
234enum TermKind {
235 AsciiLike,
236 UnicodeWord,
237 HanContaining,
238}
239
240fn classify_term(term: &str) -> TermKind {
241 let mut has_han = false;
242 let mut has_unicode_word = false;
243 for c in term.chars() {
244 match classify_char(c) {
245 CharClass::AsciiWord => {}
246 CharClass::Han => has_han = true,
247 CharClass::UnicodeWord => has_unicode_word = true,
248 CharClass::Other => {}
249 }
250 }
251
252 if has_han {
253 TermKind::HanContaining
254 } else if has_unicode_word {
255 TermKind::UnicodeWord
256 } else {
257 TermKind::AsciiLike
258 }
259}
260
261fn boundary_ok(term_kind: TermKind, neighbor: Option<char>, term_has_other_boundary: bool) -> bool {
262 if term_has_other_boundary {
263 return true;
264 }
265
266 match term_kind {
267 TermKind::AsciiLike => !matches!(neighbor.map(classify_char), Some(CharClass::AsciiWord)),
268 TermKind::UnicodeWord => !matches!(
269 neighbor.map(classify_char),
270 Some(CharClass::AsciiWord | CharClass::UnicodeWord | CharClass::Han)
271 ),
272 TermKind::HanContaining => true,
273 }
274}
275
276impl MatchesTermFinder {
277 pub fn new(term: &str) -> Self {
279 let starts_with_other = term
280 .chars()
281 .next()
282 .is_some_and(|c| classify_char(c) == CharClass::Other);
283 let ends_with_other = term
284 .chars()
285 .last()
286 .is_some_and(|c| classify_char(c) == CharClass::Other);
287 Self {
288 finder: memmem::Finder::new(term).into_owned(),
289 term: term.to_string(),
290 term_kind: classify_term(term),
291 starts_with_other,
292 ends_with_other,
293 }
294 }
295
296 pub fn find(&self, text: &str) -> bool {
298 if self.term.is_empty() {
299 return text.is_empty();
300 }
301
302 if text.len() < self.term.len() {
303 return false;
304 }
305
306 let mut pos = 0;
307 while let Some(found_pos) = self.finder.find(&text.as_bytes()[pos..]) {
308 let actual_pos = pos + found_pos;
309
310 let prev = text[..actual_pos].chars().last();
311 let prev_ok = self.starts_with_other || boundary_ok(self.term_kind, prev, false);
312
313 if prev_ok {
314 if self.term_kind == TermKind::HanContaining {
315 return true;
316 }
317
318 let next_pos = actual_pos + self.finder.needle().len();
319 let next = text[next_pos..].chars().next();
320 let next_ok = self.ends_with_other || boundary_ok(self.term_kind, next, false);
321
322 if next_ok {
323 return true;
324 }
325 }
326
327 if let Some(next_char) = text[actual_pos..].chars().next() {
328 pos = actual_pos + next_char.len_utf8();
329 } else {
330 break;
331 }
332 }
333
334 false
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 #[test]
343 fn matches_term_example() {
344 let finder = MatchesTermFinder::new("hello world");
345 assert!(finder.find("warning:hello world!"));
346 assert!(!finder.find("hello-world"));
347 assert!(!finder.find("hello world2023"));
348
349 let finder = MatchesTermFinder::new("critical error");
350 assert!(finder.find("ERROR:critical error!"));
351 assert!(!finder.find("critical_errors"));
352
353 let finder = MatchesTermFinder::new("");
354 assert!(finder.find(""));
355 assert!(!finder.find("any"));
356
357 let finder = MatchesTermFinder::new("Cat");
358 assert!(finder.find("Cat"));
359 assert!(!finder.find("cat"));
360 }
361
362 #[test]
363 fn matches_term_with_punctuation() {
364 assert!(MatchesTermFinder::new("cat").find("cat!"));
365 assert!(MatchesTermFinder::new("dog").find("!dog"));
366 }
367
368 #[test]
369 fn matches_phrase_with_boundaries() {
370 assert!(MatchesTermFinder::new("hello-world").find("hello-world"));
371 assert!(MatchesTermFinder::new("'foo bar'").find("test: 'foo bar'"));
372 }
373
374 #[test]
375 fn matches_at_text_boundaries() {
376 assert!(MatchesTermFinder::new("start").find("start..."));
377 assert!(MatchesTermFinder::new("end").find("...end"));
378 }
379
380 #[test]
382 fn rejects_partial_matches() {
383 assert!(!MatchesTermFinder::new("cat").find("category"));
384 assert!(!MatchesTermFinder::new("boot").find("rebooted"));
385 }
386
387 #[test]
388 fn rejects_missing_term() {
389 assert!(!MatchesTermFinder::new("foo").find("hello world"));
390 }
391
392 #[test]
394 fn handles_empty_inputs() {
395 assert!(!MatchesTermFinder::new("test").find(""));
396 assert!(!MatchesTermFinder::new("").find("text"));
397 }
398
399 #[test]
400 fn different_unicode_boundaries() {
401 assert!(MatchesTermFinder::new("café").find("café>"));
402 assert!(!MatchesTermFinder::new("café").find("口café>"));
403 assert!(!MatchesTermFinder::new("café").find("café口"));
404 assert!(!MatchesTermFinder::new("café").find("cafémore"));
405 assert!(MatchesTermFinder::new("русский").find("русский!"));
406 assert!(MatchesTermFinder::new("русский").find("русский!"));
407 }
408
409 #[test]
410 fn case_sensitive_matching() {
411 assert!(!MatchesTermFinder::new("cat").find("Cat"));
412 assert!(MatchesTermFinder::new("CaT").find("CaT"));
413 }
414
415 #[test]
416 fn numbers_in_term() {
417 assert!(MatchesTermFinder::new("v1.0").find("v1.0!"));
418 assert!(!MatchesTermFinder::new("v1.0").find("v1.0a"));
419 }
420
421 #[test]
422 fn mixed_script_terms_match_inside_chinese_context() {
423 let text = "登录手机号18888888888的动态key";
424 assert!(MatchesTermFinder::new("手机号").find(text));
425 assert!(MatchesTermFinder::new("18888888888").find(text));
426 assert!(MatchesTermFinder::new("手机").find(text));
427 assert!(MatchesTermFinder::new("机号").find(text));
428 assert!(MatchesTermFinder::new("机号1888").find(text));
429 assert!(MatchesTermFinder::new("农业").find("中国农业银行"));
430 assert!(MatchesTermFinder::new("error").find("错误error日志"));
431 }
432
433 #[test]
434 fn underscore_still_counts_as_boundary_for_ascii_terms() {
435 assert!(MatchesTermFinder::new("world").find("hello_world"));
436 assert!(MatchesTermFinder::new("id").find("trace_id=abc"));
437 assert!(!MatchesTermFinder::new("error").find("criticalerrors"));
438 }
439
440 #[test]
441 fn adjacent_alphanumeric_fails() {
442 assert!(!MatchesTermFinder::new("cat").find("cat5"));
443 assert!(!MatchesTermFinder::new("dog").find("dogcat"));
444 }
445
446 #[test]
447 fn empty_term_text() {
448 assert!(!MatchesTermFinder::new("").find("text"));
449 assert!(MatchesTermFinder::new("").find(""));
450 assert!(!MatchesTermFinder::new("text").find(""));
451 }
452
453 #[test]
454 fn leading_non_alphanumeric() {
455 assert!(MatchesTermFinder::new("/cat").find("dog/cat"));
456 assert!(MatchesTermFinder::new("dog/").find("dog/cat"));
457 assert!(MatchesTermFinder::new("dog/cat").find("dog/cat"));
458 }
459
460 #[test]
461 fn continues_searching_after_boundary_mismatch() {
462 assert!(!MatchesTermFinder::new("log").find("bloglog!"));
463 assert!(MatchesTermFinder::new("log").find("bloglog log"));
464 assert!(MatchesTermFinder::new("log").find("alogblog_log!"));
465
466 assert!(MatchesTermFinder::new("error").find("errorlog_error_case"));
467 assert!(MatchesTermFinder::new("test").find("atestbtestc_test_end"));
468 assert!(MatchesTermFinder::new("data").find("database_data_store"));
469 assert!(!MatchesTermFinder::new("data").find("database_datastore"));
470 assert!(MatchesTermFinder::new("log.txt").find("catalog.txt_log.txt!"));
471 assert!(!MatchesTermFinder::new("log.txt").find("catalog.txtlog.txt!"));
472 assert!(MatchesTermFinder::new("data-set").find("bigdata-set_data-set!"));
473
474 assert!(MatchesTermFinder::new("中文").find("这是中文测试,中文!"));
475 assert!(MatchesTermFinder::new("error").find("错误errorerror日志_error!"));
476 }
477
478 #[test]
479 fn han_terms_match_as_contiguous_substrings() {
480 assert!(MatchesTermFinder::new("行账号").find("中国农业银行账号"));
481 assert!(MatchesTermFinder::new("登录").find("登录手机号18888888888的动态key"));
482 }
483}