Skip to main content

icydb_core/value/ops/
text.rs

1//! Module: value::ops::text
2//!
3//! Responsibility: text and casefolded identifier operations for `Value`.
4//! Does not own: collection membership or predicate-level coercion policy.
5//! Boundary: representation-local text helpers used by query operators.
6
7use crate::value::{TextMode, Value};
8use std::borrow::Cow;
9
10/// Apply the canonical case-insensitive text fold.
11///
12/// Casefolding remains a distinct semantic contract from SQL `LOWER`, even
13/// while both use Unicode lowercase conversion today. A future full Unicode
14/// casefold must not silently change `LOWER` or persisted index expressions.
15#[must_use]
16pub(crate) fn casefold_text(input: &str) -> String {
17    lowercase_text(input)
18}
19
20/// Apply the canonical `LOWER` transform used by query and index expressions.
21#[must_use]
22pub(crate) fn lower_text(input: &str) -> String {
23    lowercase_text(input)
24}
25
26/// Conservative construction allowance for `lower_text` on the pinned Rust
27/// 1.98.1 implementation: cumulative requested backing and byte-work units.
28/// This is not allocator telemetry or an IC instruction estimate.
29#[must_use]
30pub(crate) fn lower_text_construction_allowance(input_len: usize) -> (u64, u64) {
31    let len = input_len as u64;
32    if len <= 1 {
33        // Empty/single-byte UTF-8 is necessarily ASCII and never grows:
34        // inspect, copy, then lowercase the copied bytes in place.
35        return (len, len.saturating_mul(3));
36    }
37    // Unicode lowercase output is at most twice the input byte length (the
38    // exhaustive mapping test pins this). Rust starts at `len`, then at most
39    // once grows to max(2 * len, 8), including its small byte-vector minimum.
40    let backing = len.saturating_add(len.saturating_mul(2).max(8));
41    // Input work: ASCII check (n), ASCII-prefix conversion (<=2n), scalar walk
42    // (n), and final-sigma context scans (<=2n: Sigma is not case-ignorable).
43    // Backing also covers output fills and the retained-prefix copy on growth.
44    (backing, len.saturating_mul(6).saturating_add(backing))
45}
46
47/// Apply the canonical `UPPER` transform used by query and index expressions.
48#[must_use]
49pub(crate) fn upper_text(input: &str) -> String {
50    if input.is_ascii() {
51        return input.to_ascii_uppercase();
52    }
53
54    input.to_uppercase()
55}
56
57/// Conservative cumulative backing and byte-work allowance for `upper_text`.
58///
59/// Rust's scalar mappings expand to at most three input byte lengths. Starting
60/// at the input capacity therefore needs at most two geometric growths; include
61/// both old and new requested backing rather than treating output length as
62/// allocator telemetry. The mapping and capacity controls below pin this bound.
63#[must_use]
64pub(crate) fn upper_text_construction_allowance(input_len: usize) -> (u64, u64) {
65    let len = input_len as u64;
66    if len <= 1 {
67        return (len, len.saturating_mul(3));
68    }
69    let first_growth = len.saturating_mul(2).max(8);
70    let backing = len
71        .saturating_add(first_growth)
72        .saturating_add(first_growth.saturating_mul(2));
73    (backing, len.saturating_mul(4).saturating_add(backing))
74}
75
76fn lowercase_text(input: &str) -> String {
77    if input.is_ascii() {
78        return input.to_ascii_lowercase();
79    }
80
81    input.to_lowercase()
82}
83
84fn text_with_mode(s: &'_ str, mode: TextMode) -> Cow<'_, str> {
85    match mode {
86        TextMode::Cs => Cow::Borrowed(s),
87        TextMode::Ci => Cow::Owned(casefold_text(s)),
88    }
89}
90
91fn text_op(
92    left: &Value,
93    right: &Value,
94    mode: TextMode,
95    f: impl Fn(&str, &str) -> bool,
96) -> Option<bool> {
97    let (a, b) = (left.as_text()?, right.as_text()?);
98    let a = text_with_mode(a, mode);
99    let b = text_with_mode(b, mode);
100    Some(f(&a, &b))
101}
102
103fn ci_key(value: &Value) -> Option<String> {
104    match value {
105        Value::Text(s) => Some(casefold_text(s)),
106        Value::Ulid(u) => Some(u.to_string().to_ascii_lowercase()),
107        Value::Principal(p) => Some(p.to_string().to_ascii_lowercase()),
108        Value::Account(a) => Some(a.to_string().to_ascii_lowercase()),
109        _ => None,
110    }
111}
112
113pub(super) fn eq_ci(left: &Value, right: &Value) -> bool {
114    if let (Some(left_key), Some(right_key)) = (ci_key(left), ci_key(right)) {
115        return left_key == right_key;
116    }
117
118    left == right
119}
120
121/// Case-sensitive/insensitive equality check for text-like values.
122#[must_use]
123fn text_eq(left: &Value, right: &Value, mode: TextMode) -> Option<bool> {
124    text_op(left, right, mode, |a, b| a == b)
125}
126
127/// Check whether `needle` is a substring of `value` under the given text mode.
128#[must_use]
129fn text_contains(value: &Value, needle: &Value, mode: TextMode) -> Option<bool> {
130    text_op(value, needle, mode, |a, b| a.contains(b))
131}
132
133/// Check whether `value` starts with `needle` under the given text mode.
134#[must_use]
135fn text_starts_with(value: &Value, needle: &Value, mode: TextMode) -> Option<bool> {
136    text_op(value, needle, mode, |a, b| a.starts_with(b))
137}
138
139/// Check whether `value` ends with `needle` under the given text mode.
140#[must_use]
141fn text_ends_with(value: &Value, needle: &Value, mode: TextMode) -> Option<bool> {
142    text_op(value, needle, mode, |a, b| a.ends_with(b))
143}
144
145impl Value {
146    /// Case-sensitive/insensitive equality check for text-like values.
147    #[must_use]
148    pub fn text_eq(&self, other: &Self, mode: TextMode) -> Option<bool> {
149        text_eq(self, other, mode)
150    }
151
152    /// Check whether `other` is a substring of `self` under the given text mode.
153    #[must_use]
154    pub fn text_contains(&self, needle: &Self, mode: TextMode) -> Option<bool> {
155        text_contains(self, needle, mode)
156    }
157
158    /// Check whether `self` starts with `other` under the given text mode.
159    #[must_use]
160    pub fn text_starts_with(&self, needle: &Self, mode: TextMode) -> Option<bool> {
161        text_starts_with(self, needle, mode)
162    }
163
164    /// Check whether `self` ends with `other` under the given text mode.
165    #[must_use]
166    pub fn text_ends_with(&self, needle: &Self, mode: TextMode) -> Option<bool> {
167        text_ends_with(self, needle, mode)
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::{
174        casefold_text, lower_text, lower_text_construction_allowance, upper_text,
175        upper_text_construction_allowance,
176    };
177
178    #[test]
179    fn uppercase_allowance_covers_unicode_expansion_and_cumulative_growth() {
180        for scalar in (0..=u32::from(char::MAX)).filter_map(char::from_u32) {
181            let output_bytes: usize = scalar.to_uppercase().map(char::len_utf8).sum();
182            assert!(output_bytes <= 3 * scalar.len_utf8(), "{scalar:?}");
183        }
184        for text in ["", "a", "ß", "ΐ", "aΐ", "ffi", "é🍋", "ASCII"] {
185            for repeats in [1, 2, 16, 1024] {
186                let input = text.repeat(repeats);
187                let output = upper_text(&input);
188                let initial = input.len();
189                let first_growth = (2 * initial).max(8);
190                let requested = if output.capacity() > first_growth {
191                    initial + first_growth + output.capacity()
192                } else if output.capacity() > initial {
193                    initial + output.capacity()
194                } else {
195                    initial
196                };
197                let (backing, work) = upper_text_construction_allowance(input.len());
198                assert!(requested as u64 <= backing, "{input:?}");
199                assert!(output.len() as u64 <= work);
200                assert_eq!(output, input.to_uppercase());
201            }
202        }
203        assert_eq!(upper_text_construction_allowance(0), (0, 0));
204        assert!(upper_text_construction_allowance(usize::MAX).0 >= usize::MAX as u64);
205    }
206
207    #[test]
208    fn lowercase_allowance_covers_unicode_expansion_and_output_growth() {
209        // Allocation-free qualification of every scalar mapping in the pinned
210        // toolchain. Contextual sigma changes spelling, not UTF-8 width.
211        for scalar in (0..=u32::from(char::MAX)).filter_map(char::from_u32) {
212            let output_bytes: usize = scalar.to_lowercase().map(char::len_utf8).sum();
213            assert!(output_bytes <= 2 * scalar.len_utf8(), "{scalar:?}");
214        }
215        for text in ["", "A", "İ", "Aİ", "İΣ", "ΟΣ\u{301}", "ΣΑ", "ASCII"] {
216            for repeats in [1, 2, 16, 1024] {
217                let input = text.repeat(repeats);
218                let output = lower_text(&input);
219                let (backing, _) = lower_text_construction_allowance(input.len());
220                let requested = if output.capacity() > input.len() {
221                    input.len() + output.capacity()
222                } else {
223                    input.len()
224                };
225                assert!(requested as u64 <= backing);
226                assert_eq!(output, input.to_lowercase());
227            }
228        }
229    }
230
231    #[test]
232    fn canonical_text_transforms_preserve_current_ascii_and_unicode_semantics() {
233        assert_eq!(casefold_text("IcYDB"), "icydb");
234        assert_eq!(lower_text("IcYDB"), "icydb");
235        assert_eq!(upper_text("IcYDB"), "ICYDB");
236
237        assert_eq!(casefold_text("Straße"), "straße");
238        assert_eq!(lower_text("Straße"), "straße");
239        assert_eq!(upper_text("Straße"), "STRASSE");
240    }
241}