Skip to main content

icydb_core/db/query/predicate/
coercion.rs

1use crate::value::{TextMode, Value, ValueFamily};
2use std::{cmp::Ordering, collections::BTreeMap, mem::discriminant};
3
4///
5/// Predicate coercion and comparison semantics
6///
7/// Defines which runtime value comparisons are permitted under
8/// explicit coercion policies, and how those comparisons behave.
9/// This module is schema-agnostic and planner-agnostic; it operates
10/// purely on runtime `Value`s and declared coercion intent.
11///
12
13///
14/// CoercionId
15///
16/// Identifier for an explicit coercion policy.
17///
18/// Coercions express *how* values may be compared, not whether
19/// a comparison is semantically valid for a given field.
20/// Validation and planning enforce legality separately.
21///
22/// CollectionElement is used when comparing a scalar literal
23/// against individual elements of a collection field.
24/// It must never be used for scalar-vs-scalar comparisons.
25///
26
27#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
28pub enum CoercionId {
29    Strict,
30    NumericWiden,
31    TextCasefold,
32    CollectionElement,
33}
34
35///
36/// CoercionSpec
37///
38/// Fully-specified coercion policy.
39///
40/// Carries a coercion identifier plus optional parameters.
41/// Parameters are currently unused but reserved for future
42/// extensions without changing the predicate AST.
43///
44
45#[derive(Clone, Debug, Eq, PartialEq)]
46pub struct CoercionSpec {
47    pub id: CoercionId,
48    pub params: BTreeMap<String, String>,
49}
50
51impl CoercionSpec {
52    #[must_use]
53    pub const fn new(id: CoercionId) -> Self {
54        Self {
55            id,
56            params: BTreeMap::new(),
57        }
58    }
59}
60
61impl Default for CoercionSpec {
62    fn default() -> Self {
63        Self::new(CoercionId::Strict)
64    }
65}
66
67///
68/// CoercionFamily
69///
70
71#[derive(Clone, Copy, Debug, Eq, PartialEq)]
72pub enum CoercionFamily {
73    Any,
74    Family(ValueFamily),
75}
76
77///
78/// CoercionRule
79///
80/// Declarative table defining which coercions are supported
81/// between value families.
82///
83/// This table is intentionally conservative; absence of a rule
84/// means the coercion is not permitted.
85///
86
87#[derive(Clone, Copy, Debug, Eq, PartialEq)]
88pub struct CoercionRule {
89    pub left: CoercionFamily,
90    pub right: CoercionFamily,
91    pub id: CoercionId,
92}
93
94pub const COERCION_TABLE: &[CoercionRule] = &[
95    CoercionRule {
96        left: CoercionFamily::Any,
97        right: CoercionFamily::Any,
98        id: CoercionId::Strict,
99    },
100    CoercionRule {
101        left: CoercionFamily::Family(ValueFamily::Numeric),
102        right: CoercionFamily::Family(ValueFamily::Numeric),
103        id: CoercionId::NumericWiden,
104    },
105    CoercionRule {
106        left: CoercionFamily::Family(ValueFamily::Textual),
107        right: CoercionFamily::Family(ValueFamily::Textual),
108        id: CoercionId::TextCasefold,
109    },
110    CoercionRule {
111        left: CoercionFamily::Any,
112        right: CoercionFamily::Any,
113        id: CoercionId::CollectionElement,
114    },
115];
116
117#[must_use]
118pub fn supports_coercion(left: ValueFamily, right: ValueFamily, id: CoercionId) -> bool {
119    COERCION_TABLE.iter().any(|rule| {
120        rule.id == id && family_matches(rule.left, left) && family_matches(rule.right, right)
121    })
122}
123
124fn family_matches(rule: CoercionFamily, value: ValueFamily) -> bool {
125    match rule {
126        CoercionFamily::Any => true,
127        CoercionFamily::Family(expected) => expected == value,
128    }
129}
130
131///
132/// TextOp
133///
134
135#[derive(Clone, Copy, Debug, Eq, PartialEq)]
136pub enum TextOp {
137    Eq,
138    Contains,
139    StartsWith,
140    EndsWith,
141}
142
143/// Perform equality comparison under an explicit coercion.
144///
145/// Returns `None` if the comparison is not defined for the
146/// given values and coercion.
147#[must_use]
148pub fn compare_eq(left: &Value, right: &Value, coercion: &CoercionSpec) -> Option<bool> {
149    match coercion.id {
150        CoercionId::Strict | CoercionId::CollectionElement => {
151            same_variant(left, right).then_some(left == right)
152        }
153        CoercionId::NumericWiden => left.cmp_numeric(right).map(|ord| ord == Ordering::Equal),
154        CoercionId::TextCasefold => compare_casefold(left, right),
155    }
156}
157
158/// Perform ordering comparison under an explicit coercion.
159///
160/// Returns `None` if ordering is undefined for the given
161/// values or coercion.
162#[must_use]
163pub fn compare_order(left: &Value, right: &Value, coercion: &CoercionSpec) -> Option<Ordering> {
164    match coercion.id {
165        CoercionId::Strict | CoercionId::CollectionElement => {
166            if !same_variant(left, right) {
167                return None;
168            }
169            strict_ordering(left, right)
170        }
171        CoercionId::NumericWiden => left.cmp_numeric(right),
172        CoercionId::TextCasefold => {
173            let left = casefold_value(left)?;
174            let right = casefold_value(right)?;
175            Some(left.cmp(&right))
176        }
177    }
178}
179
180/// Canonical total ordering for database semantics.
181///
182/// This is the only ordering used for:
183/// - ORDER BY
184/// - range planning
185/// - key comparisons
186#[must_use]
187pub(crate) fn canonical_cmp(left: &Value, right: &Value) -> Ordering {
188    if let Some(ordering) = strict_ordering(left, right) {
189        return ordering;
190    }
191
192    canonical_rank(left).cmp(&canonical_rank(right))
193}
194
195const fn canonical_rank(value: &Value) -> u8 {
196    match value {
197        Value::Account(_) => 0,
198        Value::Blob(_) => 1,
199        Value::Bool(_) => 2,
200        Value::Date(_) => 3,
201        Value::Decimal(_) => 4,
202        Value::Duration(_) => 5,
203        Value::Enum(_) => 6,
204        Value::E8s(_) => 7,
205        Value::E18s(_) => 8,
206        Value::Float32(_) => 9,
207        Value::Float64(_) => 10,
208        Value::Int(_) => 11,
209        Value::Int128(_) => 12,
210        Value::IntBig(_) => 13,
211        Value::List(_) => 14,
212        Value::None => 15,
213        Value::Principal(_) => 16,
214        Value::Subaccount(_) => 17,
215        Value::Text(_) => 18,
216        Value::Timestamp(_) => 19,
217        Value::Uint(_) => 20,
218        Value::Uint128(_) => 21,
219        Value::UintBig(_) => 22,
220        Value::Ulid(_) => 23,
221        Value::Unit => 24,
222        Value::Unsupported => 25,
223    }
224}
225
226/// Perform text-specific comparison operations.
227///
228/// Only strict and casefold coercions are supported.
229/// Other coercions return `None`.
230#[must_use]
231pub fn compare_text(
232    left: &Value,
233    right: &Value,
234    coercion: &CoercionSpec,
235    op: TextOp,
236) -> Option<bool> {
237    if !matches!(left, Value::Text(_)) || !matches!(right, Value::Text(_)) {
238        // CONTRACT: text coercions never apply to non-text values.
239        return None;
240    }
241
242    let mode = match coercion.id {
243        CoercionId::Strict => TextMode::Cs,
244        CoercionId::TextCasefold => TextMode::Ci,
245        _ => return None,
246    };
247
248    match op {
249        TextOp::Eq => left.text_eq(right, mode),
250        TextOp::Contains => left.text_contains(right, mode),
251        TextOp::StartsWith => left.text_starts_with(right, mode),
252        TextOp::EndsWith => left.text_ends_with(right, mode),
253    }
254}
255
256fn same_variant(left: &Value, right: &Value) -> bool {
257    discriminant(left) == discriminant(right)
258}
259
260/// Strict ordering for identical value variants.
261///
262/// Returns `None` if values are of different variants
263/// or do not support ordering.
264fn strict_ordering(left: &Value, right: &Value) -> Option<Ordering> {
265    match (left, right) {
266        (Value::Account(a), Value::Account(b)) => Some(a.cmp(b)),
267        (Value::Bool(a), Value::Bool(b)) => a.partial_cmp(b),
268        (Value::Date(a), Value::Date(b)) => a.partial_cmp(b),
269        (Value::Decimal(a), Value::Decimal(b)) => a.partial_cmp(b),
270        (Value::Duration(a), Value::Duration(b)) => a.partial_cmp(b),
271        (Value::E8s(a), Value::E8s(b)) => a.partial_cmp(b),
272        (Value::E18s(a), Value::E18s(b)) => a.partial_cmp(b),
273        (Value::Enum(a), Value::Enum(b)) => a.partial_cmp(b),
274        (Value::Float32(a), Value::Float32(b)) => a.partial_cmp(b),
275        (Value::Float64(a), Value::Float64(b)) => a.partial_cmp(b),
276        (Value::Int(a), Value::Int(b)) => a.partial_cmp(b),
277        (Value::Int128(a), Value::Int128(b)) => a.partial_cmp(b),
278        (Value::IntBig(a), Value::IntBig(b)) => a.partial_cmp(b),
279        (Value::Principal(a), Value::Principal(b)) => a.partial_cmp(b),
280        (Value::Subaccount(a), Value::Subaccount(b)) => a.partial_cmp(b),
281        (Value::Text(a), Value::Text(b)) => a.partial_cmp(b),
282        (Value::Timestamp(a), Value::Timestamp(b)) => a.partial_cmp(b),
283        (Value::Uint(a), Value::Uint(b)) => a.partial_cmp(b),
284        (Value::Uint128(a), Value::Uint128(b)) => a.partial_cmp(b),
285        (Value::UintBig(a), Value::UintBig(b)) => a.partial_cmp(b),
286        (Value::Ulid(a), Value::Ulid(b)) => a.partial_cmp(b),
287        (Value::Unit, Value::Unit) => Some(Ordering::Equal),
288        _ => {
289            // NOTE: Non-matching or non-orderable variants do not define ordering.
290            None
291        }
292    }
293}
294
295fn compare_casefold(left: &Value, right: &Value) -> Option<bool> {
296    let left = casefold_value(left)?;
297    let right = casefold_value(right)?;
298    Some(left == right)
299}
300
301/// Convert a value to its casefolded textual representation,
302/// if supported.
303fn casefold_value(value: &Value) -> Option<String> {
304    match value {
305        Value::Text(text) => Some(casefold(text)),
306        // CONTRACT: identifiers and structured values never casefold.
307        _ => {
308            // NOTE: Non-text values do not casefold.
309            None
310        }
311    }
312}
313
314fn casefold(input: &str) -> String {
315    if input.is_ascii() {
316        return input.to_ascii_lowercase();
317    }
318
319    // Unicode fallback; matches Value::text_* casefold behavior.
320    input.to_lowercase()
321}
322
323#[cfg(test)]
324mod tests {
325    use super::canonical_cmp;
326    use crate::{types::Account, value::Value};
327    use std::cmp::Ordering;
328
329    #[test]
330    fn canonical_cmp_orders_accounts() {
331        let left = Value::Account(Account::dummy(1));
332        let right = Value::Account(Account::dummy(2));
333
334        assert_eq!(canonical_cmp(&left, &right), Ordering::Less);
335        assert_eq!(canonical_cmp(&right, &left), Ordering::Greater);
336    }
337
338    #[test]
339    fn canonical_cmp_is_total_for_mixed_variants() {
340        let left = Value::Account(Account::dummy(1));
341        let right = Value::Text("x".to_string());
342
343        assert_ne!(canonical_cmp(&left, &right), Ordering::Equal);
344        assert_eq!(
345            canonical_cmp(&left, &right),
346            canonical_cmp(&right, &left).reverse()
347        );
348    }
349}