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
35impl CoercionId {
36    /// Stable tag used by plan hash encodings (fingerprint/continuation).
37    #[must_use]
38    pub const fn plan_hash_tag(self) -> u8 {
39        match self {
40            Self::Strict => 0x01,
41            Self::NumericWiden => 0x02,
42            Self::TextCasefold => 0x04,
43            Self::CollectionElement => 0x05,
44        }
45    }
46}
47
48///
49/// CoercionSpec
50///
51/// Fully-specified coercion policy.
52///
53/// Carries a coercion identifier plus optional parameters.
54/// Parameters are currently unused but reserved for future
55/// extensions without changing the predicate AST.
56///
57
58#[derive(Clone, Debug, Eq, PartialEq)]
59pub struct CoercionSpec {
60    pub id: CoercionId,
61    pub params: BTreeMap<String, String>,
62}
63
64impl CoercionSpec {
65    #[must_use]
66    pub const fn new(id: CoercionId) -> Self {
67        Self {
68            id,
69            params: BTreeMap::new(),
70        }
71    }
72}
73
74impl Default for CoercionSpec {
75    fn default() -> Self {
76        Self::new(CoercionId::Strict)
77    }
78}
79
80///
81/// CoercionRuleFamily
82///
83/// Rule-side matcher for coercion routing families.
84/// This exists only to express "any" versus an exact family in the coercion table.
85///
86#[derive(Clone, Copy, Debug, Eq, PartialEq)]
87pub(crate) enum CoercionRuleFamily {
88    Any,
89    Family(CoercionFamily),
90}
91
92///
93/// CoercionRule
94///
95/// Declarative table defining which coercions are supported
96/// between value families.
97///
98/// This table is intentionally conservative; absence of a rule
99/// means the coercion is not permitted.
100///
101
102#[derive(Clone, Copy, Debug, Eq, PartialEq)]
103pub(crate) struct CoercionRule {
104    pub left: CoercionRuleFamily,
105    pub right: CoercionRuleFamily,
106    pub id: CoercionId,
107}
108
109// CoercionFamily is a routing category only.
110// Capability checks (numeric coercion eligibility, etc.) are registry-driven
111// and must be applied before consulting this table.
112pub(crate) const COERCION_TABLE: &[CoercionRule] = &[
113    CoercionRule {
114        left: CoercionRuleFamily::Any,
115        right: CoercionRuleFamily::Any,
116        id: CoercionId::Strict,
117    },
118    CoercionRule {
119        left: CoercionRuleFamily::Family(CoercionFamily::Numeric),
120        right: CoercionRuleFamily::Family(CoercionFamily::Numeric),
121        id: CoercionId::NumericWiden,
122    },
123    CoercionRule {
124        left: CoercionRuleFamily::Family(CoercionFamily::Textual),
125        right: CoercionRuleFamily::Family(CoercionFamily::Textual),
126        id: CoercionId::TextCasefold,
127    },
128    CoercionRule {
129        left: CoercionRuleFamily::Any,
130        right: CoercionRuleFamily::Any,
131        id: CoercionId::CollectionElement,
132    },
133];
134
135/// Returns whether a coercion rule exists for the provided routing families.
136#[must_use]
137pub(crate) fn supports_coercion(
138    left: CoercionFamily,
139    right: CoercionFamily,
140    id: CoercionId,
141) -> bool {
142    COERCION_TABLE.iter().any(|rule| {
143        rule.id == id && family_matches(rule.left, left) && family_matches(rule.right, right)
144    })
145}
146
147fn family_matches(rule: CoercionRuleFamily, value: CoercionFamily) -> bool {
148    match rule {
149        CoercionRuleFamily::Any => true,
150        CoercionRuleFamily::Family(expected) => expected == value,
151    }
152}
153
154///
155/// TextOp
156///
157
158#[derive(Clone, Copy, Debug, Eq, PartialEq)]
159pub(crate) enum TextOp {
160    StartsWith,
161    EndsWith,
162}
163
164/// Perform equality comparison under an explicit coercion.
165///
166/// Returns `None` if the comparison is not defined for the
167/// given values and coercion.
168#[must_use]
169pub(crate) fn compare_eq(left: &Value, right: &Value, coercion: &CoercionSpec) -> Option<bool> {
170    match coercion.id {
171        CoercionId::Strict | CoercionId::CollectionElement => {
172            same_variant(left, right).then_some(left == right)
173        }
174        CoercionId::NumericWiden => {
175            if !left.supports_numeric_coercion() || !right.supports_numeric_coercion() {
176                return None;
177            }
178
179            left.cmp_numeric(right).map(|ord| ord == Ordering::Equal)
180        }
181        CoercionId::TextCasefold => compare_casefold(left, right),
182    }
183}
184
185/// Perform ordering comparison under an explicit coercion.
186///
187/// Returns `None` if ordering is undefined for the given
188/// values or coercion.
189#[must_use]
190pub(crate) fn compare_order(
191    left: &Value,
192    right: &Value,
193    coercion: &CoercionSpec,
194) -> Option<Ordering> {
195    match coercion.id {
196        CoercionId::Strict | CoercionId::CollectionElement => {
197            if !same_variant(left, right) {
198                return None;
199            }
200            Value::strict_order_cmp(left, right)
201        }
202        CoercionId::NumericWiden => {
203            if !left.supports_numeric_coercion() || !right.supports_numeric_coercion() {
204                return None;
205            }
206
207            left.cmp_numeric(right)
208        }
209        CoercionId::TextCasefold => {
210            let left = casefold_value(left)?;
211            let right = casefold_value(right)?;
212            Some(left.cmp(&right))
213        }
214    }
215}
216
217/// Canonical total ordering for database semantics.
218///
219/// This is the only ordering used for:
220/// - ORDER BY
221/// - range planning
222/// - key comparisons
223#[must_use]
224pub(crate) fn canonical_cmp(left: &Value, right: &Value) -> Ordering {
225    if let Some(ordering) = Value::strict_order_cmp(left, right) {
226        return ordering;
227    }
228
229    left.canonical_rank().cmp(&right.canonical_rank())
230}
231
232/// Perform text-specific comparison operations.
233///
234/// Only strict and casefold coercions are supported.
235/// Other coercions return `None`.
236#[must_use]
237pub(crate) fn compare_text(
238    left: &Value,
239    right: &Value,
240    coercion: &CoercionSpec,
241    op: TextOp,
242) -> Option<bool> {
243    if !matches!(left, Value::Text(_)) || !matches!(right, Value::Text(_)) {
244        // CONTRACT: text coercions never apply to non-text values.
245        return None;
246    }
247
248    let mode = match coercion.id {
249        CoercionId::Strict => TextMode::Cs,
250        CoercionId::TextCasefold => TextMode::Ci,
251        _ => return None,
252    };
253
254    match op {
255        TextOp::StartsWith => left.text_starts_with(right, mode),
256        TextOp::EndsWith => left.text_ends_with(right, mode),
257    }
258}
259
260fn same_variant(left: &Value, right: &Value) -> bool {
261    discriminant(left) == discriminant(right)
262}
263
264fn compare_casefold(left: &Value, right: &Value) -> Option<bool> {
265    let left = casefold_value(left)?;
266    let right = casefold_value(right)?;
267    Some(left == right)
268}
269
270/// Convert a value to its casefolded textual representation,
271/// if supported.
272fn casefold_value(value: &Value) -> Option<String> {
273    match value {
274        Value::Text(text) => Some(casefold(text)),
275        // CONTRACT: identifiers and structured values never casefold.
276        _ => {
277            // NOTE: Non-text values do not casefold.
278            None
279        }
280    }
281}
282
283fn casefold(input: &str) -> String {
284    if input.is_ascii() {
285        return input.to_ascii_lowercase();
286    }
287
288    // Unicode fallback; matches Value::text_* casefold behavior.
289    input.to_lowercase()
290}
291
292#[cfg(test)]
293mod tests {
294    use super::canonical_cmp;
295    use crate::{types::Account, value::Value};
296    use std::cmp::Ordering;
297
298    #[test]
299    fn canonical_cmp_orders_accounts() {
300        let left = Value::Account(Account::dummy(1));
301        let right = Value::Account(Account::dummy(2));
302
303        assert_eq!(canonical_cmp(&left, &right), Ordering::Less);
304        assert_eq!(canonical_cmp(&right, &left), Ordering::Greater);
305    }
306
307    #[test]
308    fn canonical_cmp_is_total_for_mixed_variants() {
309        let left = Value::Account(Account::dummy(1));
310        let right = Value::Text("x".to_string());
311
312        assert_ne!(canonical_cmp(&left, &right), Ordering::Equal);
313        assert_eq!(
314            canonical_cmp(&left, &right),
315            canonical_cmp(&right, &left).reverse()
316        );
317    }
318}