Skip to main content

icydb_core/db/query/predicate/
coercion.rs

1use crate::value::{CoercionFamily, TextMode, Value};
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/// CoercionRuleFamily
69///
70/// Rule-side matcher for coercion routing families.
71/// This exists only to express "any" versus an exact family in the coercion table.
72///
73#[derive(Clone, Copy, Debug, Eq, PartialEq)]
74pub enum CoercionRuleFamily {
75    Any,
76    Family(CoercionFamily),
77}
78
79///
80/// CoercionRule
81///
82/// Declarative table defining which coercions are supported
83/// between value families.
84///
85/// This table is intentionally conservative; absence of a rule
86/// means the coercion is not permitted.
87///
88
89#[derive(Clone, Copy, Debug, Eq, PartialEq)]
90pub struct CoercionRule {
91    pub left: CoercionRuleFamily,
92    pub right: CoercionRuleFamily,
93    pub id: CoercionId,
94}
95
96// CoercionFamily is a routing category only.
97// Capability checks (numeric coercion eligibility, etc.) are registry-driven
98// and must be applied before consulting this table.
99pub const COERCION_TABLE: &[CoercionRule] = &[
100    CoercionRule {
101        left: CoercionRuleFamily::Any,
102        right: CoercionRuleFamily::Any,
103        id: CoercionId::Strict,
104    },
105    CoercionRule {
106        left: CoercionRuleFamily::Family(CoercionFamily::Numeric),
107        right: CoercionRuleFamily::Family(CoercionFamily::Numeric),
108        id: CoercionId::NumericWiden,
109    },
110    CoercionRule {
111        left: CoercionRuleFamily::Family(CoercionFamily::Textual),
112        right: CoercionRuleFamily::Family(CoercionFamily::Textual),
113        id: CoercionId::TextCasefold,
114    },
115    CoercionRule {
116        left: CoercionRuleFamily::Any,
117        right: CoercionRuleFamily::Any,
118        id: CoercionId::CollectionElement,
119    },
120];
121
122/// Returns whether a coercion rule exists for the provided routing families.
123#[must_use]
124pub fn supports_coercion(left: CoercionFamily, right: CoercionFamily, id: CoercionId) -> bool {
125    COERCION_TABLE.iter().any(|rule| {
126        rule.id == id && family_matches(rule.left, left) && family_matches(rule.right, right)
127    })
128}
129
130fn family_matches(rule: CoercionRuleFamily, value: CoercionFamily) -> bool {
131    match rule {
132        CoercionRuleFamily::Any => true,
133        CoercionRuleFamily::Family(expected) => expected == value,
134    }
135}
136
137///
138/// TextOp
139///
140
141#[derive(Clone, Copy, Debug, Eq, PartialEq)]
142pub enum TextOp {
143    Eq,
144    Contains,
145    StartsWith,
146    EndsWith,
147}
148
149/// Perform equality comparison under an explicit coercion.
150///
151/// Returns `None` if the comparison is not defined for the
152/// given values and coercion.
153#[must_use]
154pub fn compare_eq(left: &Value, right: &Value, coercion: &CoercionSpec) -> Option<bool> {
155    match coercion.id {
156        CoercionId::Strict | CoercionId::CollectionElement => {
157            same_variant(left, right).then_some(left == right)
158        }
159        CoercionId::NumericWiden => {
160            if !left.supports_numeric_coercion() || !right.supports_numeric_coercion() {
161                return None;
162            }
163
164            left.cmp_numeric(right).map(|ord| ord == Ordering::Equal)
165        }
166        CoercionId::TextCasefold => compare_casefold(left, right),
167    }
168}
169
170/// Perform ordering comparison under an explicit coercion.
171///
172/// Returns `None` if ordering is undefined for the given
173/// values or coercion.
174#[must_use]
175pub fn compare_order(left: &Value, right: &Value, coercion: &CoercionSpec) -> Option<Ordering> {
176    match coercion.id {
177        CoercionId::Strict | CoercionId::CollectionElement => {
178            if !same_variant(left, right) {
179                return None;
180            }
181            strict_ordering(left, right)
182        }
183        CoercionId::NumericWiden => {
184            if !left.supports_numeric_coercion() || !right.supports_numeric_coercion() {
185                return None;
186            }
187
188            left.cmp_numeric(right)
189        }
190        CoercionId::TextCasefold => {
191            let left = casefold_value(left)?;
192            let right = casefold_value(right)?;
193            Some(left.cmp(&right))
194        }
195    }
196}
197
198/// Canonical total ordering for database semantics.
199///
200/// This is the only ordering used for:
201/// - ORDER BY
202/// - range planning
203/// - key comparisons
204#[must_use]
205pub(crate) fn canonical_cmp(left: &Value, right: &Value) -> Ordering {
206    if let Some(ordering) = strict_ordering(left, right) {
207        return ordering;
208    }
209
210    canonical_rank(left).cmp(&canonical_rank(right))
211}
212
213const fn canonical_rank(value: &Value) -> u8 {
214    match value {
215        Value::Account(_) => 0,
216        Value::Blob(_) => 1,
217        Value::Bool(_) => 2,
218        Value::Date(_) => 3,
219        Value::Decimal(_) => 4,
220        Value::Duration(_) => 5,
221        Value::Enum(_) => 6,
222        Value::E8s(_) => 7,
223        Value::E18s(_) => 8,
224        Value::Float32(_) => 9,
225        Value::Float64(_) => 10,
226        Value::Int(_) => 11,
227        Value::Int128(_) => 12,
228        Value::IntBig(_) => 13,
229        Value::List(_) => 14,
230        Value::Map(_) => 15,
231        Value::Null => 16,
232        Value::Principal(_) => 17,
233        Value::Subaccount(_) => 18,
234        Value::Text(_) => 19,
235        Value::Timestamp(_) => 20,
236        Value::Uint(_) => 21,
237        Value::Uint128(_) => 22,
238        Value::UintBig(_) => 23,
239        Value::Ulid(_) => 24,
240        Value::Unit => 25,
241    }
242}
243
244/// Perform text-specific comparison operations.
245///
246/// Only strict and casefold coercions are supported.
247/// Other coercions return `None`.
248#[must_use]
249pub fn compare_text(
250    left: &Value,
251    right: &Value,
252    coercion: &CoercionSpec,
253    op: TextOp,
254) -> Option<bool> {
255    if !matches!(left, Value::Text(_)) || !matches!(right, Value::Text(_)) {
256        // CONTRACT: text coercions never apply to non-text values.
257        return None;
258    }
259
260    let mode = match coercion.id {
261        CoercionId::Strict => TextMode::Cs,
262        CoercionId::TextCasefold => TextMode::Ci,
263        _ => return None,
264    };
265
266    match op {
267        TextOp::Eq => left.text_eq(right, mode),
268        TextOp::Contains => left.text_contains(right, mode),
269        TextOp::StartsWith => left.text_starts_with(right, mode),
270        TextOp::EndsWith => left.text_ends_with(right, mode),
271    }
272}
273
274fn same_variant(left: &Value, right: &Value) -> bool {
275    discriminant(left) == discriminant(right)
276}
277
278/// Strict ordering for identical value variants.
279///
280/// Returns `None` if values are of different variants
281/// or do not support ordering.
282fn strict_ordering(left: &Value, right: &Value) -> Option<Ordering> {
283    match (left, right) {
284        (Value::Account(a), Value::Account(b)) => Some(a.cmp(b)),
285        (Value::Bool(a), Value::Bool(b)) => a.partial_cmp(b),
286        (Value::Date(a), Value::Date(b)) => a.partial_cmp(b),
287        (Value::Decimal(a), Value::Decimal(b)) => a.partial_cmp(b),
288        (Value::Duration(a), Value::Duration(b)) => a.partial_cmp(b),
289        (Value::E8s(a), Value::E8s(b)) => a.partial_cmp(b),
290        (Value::E18s(a), Value::E18s(b)) => a.partial_cmp(b),
291        (Value::Enum(a), Value::Enum(b)) => a.partial_cmp(b),
292        (Value::Float32(a), Value::Float32(b)) => a.partial_cmp(b),
293        (Value::Float64(a), Value::Float64(b)) => a.partial_cmp(b),
294        (Value::Int(a), Value::Int(b)) => a.partial_cmp(b),
295        (Value::Int128(a), Value::Int128(b)) => a.partial_cmp(b),
296        (Value::IntBig(a), Value::IntBig(b)) => a.partial_cmp(b),
297        (Value::Map(a), Value::Map(b)) => map_ordering(a.as_slice(), b.as_slice()),
298        (Value::Principal(a), Value::Principal(b)) => a.partial_cmp(b),
299        (Value::Subaccount(a), Value::Subaccount(b)) => a.partial_cmp(b),
300        (Value::Text(a), Value::Text(b)) => a.partial_cmp(b),
301        (Value::Timestamp(a), Value::Timestamp(b)) => a.partial_cmp(b),
302        (Value::Uint(a), Value::Uint(b)) => a.partial_cmp(b),
303        (Value::Uint128(a), Value::Uint128(b)) => a.partial_cmp(b),
304        (Value::UintBig(a), Value::UintBig(b)) => a.partial_cmp(b),
305        (Value::Ulid(a), Value::Ulid(b)) => a.partial_cmp(b),
306        (Value::Unit, Value::Unit) => Some(Ordering::Equal),
307        _ => {
308            // NOTE: Non-matching or non-orderable variants do not define ordering.
309            None
310        }
311    }
312}
313
314fn map_ordering(left: &[(Value, Value)], right: &[(Value, Value)]) -> Option<Ordering> {
315    let limit = left.len().min(right.len());
316    for ((left_key, left_value), (right_key, right_value)) in
317        left.iter().zip(right.iter()).take(limit)
318    {
319        let key_cmp = Value::canonical_cmp_key(left_key, right_key);
320        if key_cmp != Ordering::Equal {
321            return Some(key_cmp);
322        }
323
324        let value_cmp = strict_ordering(left_value, right_value)?;
325        if value_cmp != Ordering::Equal {
326            return Some(value_cmp);
327        }
328    }
329
330    left.len().partial_cmp(&right.len())
331}
332
333fn compare_casefold(left: &Value, right: &Value) -> Option<bool> {
334    let left = casefold_value(left)?;
335    let right = casefold_value(right)?;
336    Some(left == right)
337}
338
339/// Convert a value to its casefolded textual representation,
340/// if supported.
341fn casefold_value(value: &Value) -> Option<String> {
342    match value {
343        Value::Text(text) => Some(casefold(text)),
344        // CONTRACT: identifiers and structured values never casefold.
345        _ => {
346            // NOTE: Non-text values do not casefold.
347            None
348        }
349    }
350}
351
352fn casefold(input: &str) -> String {
353    if input.is_ascii() {
354        return input.to_ascii_lowercase();
355    }
356
357    // Unicode fallback; matches Value::text_* casefold behavior.
358    input.to_lowercase()
359}
360
361#[cfg(test)]
362mod tests {
363    use super::canonical_cmp;
364    use crate::{types::Account, value::Value};
365    use std::cmp::Ordering;
366
367    #[test]
368    fn canonical_cmp_orders_accounts() {
369        let left = Value::Account(Account::dummy(1));
370        let right = Value::Account(Account::dummy(2));
371
372        assert_eq!(canonical_cmp(&left, &right), Ordering::Less);
373        assert_eq!(canonical_cmp(&right, &left), Ordering::Greater);
374    }
375
376    #[test]
377    fn canonical_cmp_is_total_for_mixed_variants() {
378        let left = Value::Account(Account::dummy(1));
379        let right = Value::Text("x".to_string());
380
381        assert_ne!(canonical_cmp(&left, &right), Ordering::Equal);
382        assert_eq!(
383            canonical_cmp(&left, &right),
384            canonical_cmp(&right, &left).reverse()
385        );
386    }
387}