Skip to main content

common_base/
term_token.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Word boundaries shared by `matches_term` and the fulltext bloom index.
16//!
17//! Bloom pruning is only sound if the index splits text exactly where
18//! `matches_term` sees word boundaries, so both use [`classify_char`], and the
19//! query side derives its probes with [`extract_term_probes`] and [`extract_like_probes`].
20
21use std::ops::Range;
22
23use icu_properties::props::Script;
24use icu_properties::{CodePointMapData, CodePointMapDataBorrowed};
25
26/// Classes of characters that decide where `matches_term` sees a word boundary.
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum CharClass {
29    /// `[0-9A-Za-z]`.
30    AsciiWord,
31    /// Characters of the Han script, alphanumeric or not.
32    Han,
33    /// Non-ASCII alphanumerics outside the Han script, such as `é` or `д`.
34    UnicodeWord,
35    /// Everything else, including `_`.
36    Other,
37}
38
39/// Returns the [`CharClass`] of `c`. [`tokenize`] splits text wherever the class
40/// changes and at every [`CharClass::Other`] character.
41pub fn classify_char(c: char) -> CharClass {
42    if c.is_ascii_alphanumeric() {
43        CharClass::AsciiWord
44    } else if is_han(c) {
45        CharClass::Han
46    } else if c.is_alphanumeric() {
47        CharClass::UnicodeWord
48    } else {
49        CharClass::Other
50    }
51}
52
53static HAN_SCRIPT_DATA: CodePointMapDataBorrowed<'static, Script> =
54    CodePointMapData::<Script>::new();
55
56fn is_han(c: char) -> bool {
57    HAN_SCRIPT_DATA.get(c) == Script::Han
58}
59
60/// Returns whether lowercasing `text` as a whole can produce tokens that lowercasing
61/// each token of `text` doesn't, which `matches_term(lower(col), ..)` and
62/// `lower(col) LIKE ..` rely on. Every other character lowercases to one character
63/// of the same class, but U+0130 and U+212A change length or class, and `Σ`
64/// lowercases depending on the characters after it.
65pub fn has_irregular_lowercase(text: &str) -> bool {
66    !text.is_ascii() && text.contains(['\u{130}', '\u{212A}', 'Σ'])
67}
68
69/// Returns the tokens the fulltext bloom index stores for `text`.
70///
71/// A token is a maximal run of ASCII alphanumerics or of non-Han alphanumerics;
72/// `_` and other symbols only separate tokens. A run of Han characters is
73/// emitted as its bigrams, or as itself when it has a single character, since
74/// `matches_term` matches Han terms as plain substrings.
75pub fn tokenize(text: &str) -> Vec<&str> {
76    let mut tokens = Vec::new();
77    for_each_word_run(text, |class, range| {
78        let run = &text[range];
79        if class == CharClass::Han {
80            push_han_tokens(run, &mut tokens);
81        } else {
82            tokens.push(run);
83        }
84    });
85    tokens
86}
87
88/// Calls `f` on every maximal run of word characters of the same class.
89fn for_each_word_run(text: &str, mut f: impl FnMut(CharClass, Range<usize>)) {
90    if text.is_ascii() {
91        // ASCII text only has `AsciiWord` and `Other` characters, so scan bytes
92        // instead of decoding chars. Most log lines take this path.
93        let mut start = None;
94        for (i, b) in text.bytes().enumerate() {
95            match (b.is_ascii_alphanumeric(), start) {
96                (true, None) => start = Some(i),
97                (false, Some(s)) => {
98                    f(CharClass::AsciiWord, s..i);
99                    start = None;
100                }
101                _ => {}
102            }
103        }
104        if let Some(s) = start {
105            f(CharClass::AsciiWord, s..text.len());
106        }
107        return;
108    }
109
110    let mut run: Option<(CharClass, usize)> = None;
111    for (i, c) in text.char_indices() {
112        let class = classify_char(c);
113        if let Some((run_class, start)) = run {
114            if run_class == class {
115                continue;
116            }
117            f(run_class, start..i);
118            run = None;
119        }
120        if class != CharClass::Other {
121            run = Some((class, i));
122        }
123    }
124    if let Some((run_class, start)) = run {
125        f(run_class, start..text.len());
126    }
127}
128
129fn push_han_tokens<'a>(run: &'a str, tokens: &mut Vec<&'a str>) {
130    let mut starts = run.char_indices().map(|(i, _)| i).skip(1);
131    let Some(mut second) = starts.next() else {
132        tokens.push(run);
133        return;
134    };
135    let mut first = 0;
136    for next in starts.chain(std::iter::once(run.len())) {
137        tokens.push(&run[first..next]);
138        first = second;
139        second = next;
140    }
141}
142
143/// Returns tokens that every text satisfying `matches_term(text, term)` contains,
144/// as produced by [`tokenize`].
145pub fn extract_term_probes(term: &str) -> Vec<String> {
146    // Non-Han terms only match between word boundaries that `tokenize` also
147    // splits at, so their edge runs are whole tokens. Han-containing terms match
148    // as plain substrings and give no such guarantee at their edges.
149    let bounded = !term.chars().any(|c| classify_char(c) == CharClass::Han);
150    let mut probes = Vec::new();
151    push_literal_probes(term, bounded, bounded, &mut probes);
152    probes
153}
154
155/// Returns tokens that every text satisfying `text LIKE pattern` contains, as
156/// produced by [`tokenize`]. `\` escapes the next character, following
157/// arrow's `like` kernel; a trailing `\` is a literal backslash.
158pub fn extract_like_probes(pattern: &str) -> Vec<String> {
159    let mut probes = Vec::new();
160    let mut literal = String::new();
161    // The pattern start and end are anchors; a wildcard can stand for word
162    // characters, so a run touching it may be part of a longer token.
163    let mut anchored_left = true;
164    let mut chars = pattern.chars();
165    while let Some(c) = chars.next() {
166        match c {
167            '%' | '_' => {
168                push_literal_probes(&literal, anchored_left, false, &mut probes);
169                literal.clear();
170                anchored_left = false;
171            }
172            '\\' => literal.push(chars.next().unwrap_or('\\')),
173            c => literal.push(c),
174        }
175    }
176    push_literal_probes(&literal, anchored_left, true, &mut probes);
177    probes
178}
179
180/// Pushes the tokens of `literal` that stay whole tokens wherever `literal`
181/// occurs. A side is bounded when the character beyond it can't extend the
182/// adjacent run.
183fn push_literal_probes(
184    literal: &str,
185    left_bounded: bool,
186    right_bounded: bool,
187    probes: &mut Vec<String>,
188) {
189    for_each_word_run(literal, |class, range| {
190        let whole =
191            (range.start > 0 || left_bounded) && (range.end < literal.len() || right_bounded);
192        let run = &literal[range];
193        if class == CharClass::Han && run.chars().nth(1).is_some() {
194            // Bigrams inside the run occur in every text containing it.
195            let mut tokens = Vec::new();
196            push_han_tokens(run, &mut tokens);
197            probes.extend(tokens.into_iter().map(str::to_string));
198        } else if whole {
199            probes.push(run.to_string());
200        }
201    });
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn han_detection_uses_script_not_all_cjk() {
210        assert!(is_han('汉'));
211        assert!(is_han('\u{30000}'));
212        assert!(!is_han('あ'));
213        assert!(!is_han('한'));
214    }
215
216    #[test]
217    fn test_script_tokenizer() {
218        let cases: [(&str, &[&str]); 7] = [
219            ("hello_world __x__", &["hello", "world", "x"]),
220            ("trace_id=abc", &["trace", "id", "abc"]),
221            ("错误error日志", &["错误", "error", "日志"]),
222            ("连接timeout.5次", &["连接", "timeout", "5", "次"]),
223            ("用户user-123登录", &["用户", "user", "123", "登录"]),
224            ("中国农业银行", &["中国", "国农", "农业", "业银", "银行"]),
225            ("Größe日x", &["Gr", "öß", "e", "日", "x"]),
226        ];
227        for (text, expected) in cases {
228            assert_eq!(tokenize(text), expected, "text: {text}");
229        }
230    }
231
232    #[test]
233    fn test_irregular_lowercase_chars() {
234        for c in (0..=0x10FFFF).filter_map(char::from_u32) {
235            let lower = c.to_lowercase().collect::<Vec<_>>();
236            let regular = lower.len() == 1 && classify_char(lower[0]) == classify_char(c);
237            let expect_regular = !matches!(c, '\u{130}' | '\u{212A}');
238            assert_eq!(regular, expect_regular, "U+{:04X}", c as u32);
239        }
240    }
241
242    #[test]
243    fn test_term_probes() {
244        let cases: [(&str, &[&str]); 8] = [
245            ("world", &["world"]),
246            ("hello world", &["hello", "world"]),
247            ("/start", &["start"]),
248            ("naïve", &["na", "ï", "ve"]),
249            // Han terms match as substrings: only bigrams and runs enclosed by
250            // other characters of the term are safe.
251            ("农业", &["农业"]),
252            ("农", &[]),
253            ("机号1888", &["机号"]),
254            ("x错误y日z", &["错误", "y", "日"]),
255        ];
256        for (term, expected) in cases {
257            assert_eq!(extract_term_probes(term), expected, "term: {term}");
258        }
259    }
260
261    #[test]
262    fn test_like_probes() {
263        let cases: [(&str, &[&str]); 11] = [
264            ("%timeout%", &[]),
265            ("% timeout %", &["timeout"]),
266            ("timeout%", &[]),
267            ("timeout: %", &["timeout"]),
268            ("%: timeout", &["timeout"]),
269            ("a_b c", &["c"]),
270            (r"trace\_id=%", &["trace", "id"]),
271            (r"100\% done", &["100", "done"]),
272            (r"x\\y", &["x", "y"]),
273            ("%错误error日%", &["错误", "error"]),
274            ("%错误 x%", &["错误"]),
275        ];
276        for (pattern, expected) in cases {
277            assert_eq!(extract_like_probes(pattern), expected, "pattern: {pattern}");
278        }
279    }
280}