Skip to main content

icydb_core/db/query/predicate/
coercion.rs

1use crate::{
2    types::{Account, Principal, Ulid},
3    value::{TextMode, Value, ValueFamily},
4};
5use std::{cmp::Ordering, collections::BTreeMap, mem::discriminant, str::FromStr};
6
7///
8/// Predicate coercion and comparison semantics
9///
10/// Defines which runtime value comparisons are permitted under
11/// explicit coercion policies, and how those comparisons behave.
12/// This module is schema-agnostic and planner-agnostic; it operates
13/// purely on runtime `Value`s and declared coercion intent.
14///
15
16///
17/// CoercionId
18///
19/// Identifier for an explicit coercion policy.
20///
21/// Coercions express *how* values may be compared, not whether
22/// a comparison is semantically valid for a given field.
23/// Validation and planning enforce legality separately.
24///
25
26#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
27pub enum CoercionId {
28    Strict,
29    NumericWiden,
30    IdentifierText,
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::Identifier),
107        right: CoercionFamily::Family(ValueFamily::Textual),
108        id: CoercionId::IdentifierText,
109    },
110    CoercionRule {
111        left: CoercionFamily::Family(ValueFamily::Textual),
112        right: CoercionFamily::Family(ValueFamily::Identifier),
113        id: CoercionId::IdentifierText,
114    },
115    CoercionRule {
116        left: CoercionFamily::Family(ValueFamily::Textual),
117        right: CoercionFamily::Family(ValueFamily::Textual),
118        id: CoercionId::TextCasefold,
119    },
120    CoercionRule {
121        left: CoercionFamily::Family(ValueFamily::Identifier),
122        right: CoercionFamily::Family(ValueFamily::Identifier),
123        id: CoercionId::TextCasefold,
124    },
125    CoercionRule {
126        left: CoercionFamily::Family(ValueFamily::Identifier),
127        right: CoercionFamily::Family(ValueFamily::Textual),
128        id: CoercionId::TextCasefold,
129    },
130    CoercionRule {
131        left: CoercionFamily::Family(ValueFamily::Textual),
132        right: CoercionFamily::Family(ValueFamily::Identifier),
133        id: CoercionId::TextCasefold,
134    },
135    CoercionRule {
136        left: CoercionFamily::Any,
137        right: CoercionFamily::Any,
138        id: CoercionId::CollectionElement,
139    },
140];
141
142#[must_use]
143pub fn supports_coercion(left: ValueFamily, right: ValueFamily, id: CoercionId) -> bool {
144    COERCION_TABLE.iter().any(|rule| {
145        rule.id == id && family_matches(rule.left, left) && family_matches(rule.right, right)
146    })
147}
148
149fn family_matches(rule: CoercionFamily, value: ValueFamily) -> bool {
150    match rule {
151        CoercionFamily::Any => true,
152        CoercionFamily::Family(expected) => expected == value,
153    }
154}
155
156///
157/// TextOp
158///
159
160#[derive(Clone, Copy, Debug, Eq, PartialEq)]
161pub enum TextOp {
162    Eq,
163    Contains,
164    StartsWith,
165    EndsWith,
166}
167
168/// Perform equality comparison under an explicit coercion.
169///
170/// Returns `None` if the comparison is not defined for the
171/// given values and coercion.
172#[must_use]
173pub fn compare_eq(left: &Value, right: &Value, coercion: &CoercionSpec) -> Option<bool> {
174    match coercion.id {
175        CoercionId::Strict | CoercionId::CollectionElement => {
176            same_variant(left, right).then_some(left == right)
177        }
178        CoercionId::NumericWiden => left.cmp_numeric(right).map(|ord| ord == Ordering::Equal),
179        CoercionId::IdentifierText => {
180            let (l, r) = coerce_identifier_text(left, right)?;
181            Some(l == r)
182        }
183        CoercionId::TextCasefold => compare_casefold(left, right),
184    }
185}
186
187/// Perform ordering comparison under an explicit coercion.
188///
189/// Returns `None` if ordering is undefined for the given
190/// values or coercion.
191#[must_use]
192pub fn compare_order(left: &Value, right: &Value, coercion: &CoercionSpec) -> Option<Ordering> {
193    match coercion.id {
194        CoercionId::Strict | CoercionId::CollectionElement => {
195            if !same_variant(left, right) {
196                return None;
197            }
198            strict_ordering(left, right)
199        }
200        CoercionId::NumericWiden => left.cmp_numeric(right),
201        CoercionId::IdentifierText => {
202            let (l, r) = coerce_identifier_text(left, right)?;
203            strict_ordering(&l, &r)
204        }
205        CoercionId::TextCasefold => {
206            let left = casefold_value(left)?;
207            let right = casefold_value(right)?;
208            Some(left.cmp(&right))
209        }
210    }
211}
212
213/// Perform text-specific comparison operations.
214///
215/// Only strict and casefold coercions are supported.
216/// Other coercions return `None`.
217#[must_use]
218pub fn compare_text(
219    left: &Value,
220    right: &Value,
221    coercion: &CoercionSpec,
222    op: TextOp,
223) -> Option<bool> {
224    let mode = match coercion.id {
225        CoercionId::Strict => TextMode::Cs,
226        CoercionId::TextCasefold => TextMode::Ci,
227        _ => return None,
228    };
229
230    match op {
231        TextOp::Eq => left.text_eq(right, mode),
232        TextOp::Contains => left.text_contains(right, mode),
233        TextOp::StartsWith => left.text_starts_with(right, mode),
234        TextOp::EndsWith => left.text_ends_with(right, mode),
235    }
236}
237
238fn same_variant(left: &Value, right: &Value) -> bool {
239    discriminant(left) == discriminant(right)
240}
241
242/// Strict ordering for identical value variants.
243///
244/// Returns `None` if values are of different variants
245/// or do not support ordering.
246fn strict_ordering(left: &Value, right: &Value) -> Option<Ordering> {
247    match (left, right) {
248        (Value::Account(a), Value::Account(b)) => Some(a.cmp(b)),
249        (Value::Bool(a), Value::Bool(b)) => a.partial_cmp(b),
250        (Value::Date(a), Value::Date(b)) => a.partial_cmp(b),
251        (Value::Decimal(a), Value::Decimal(b)) => a.partial_cmp(b),
252        (Value::Duration(a), Value::Duration(b)) => a.partial_cmp(b),
253        (Value::E8s(a), Value::E8s(b)) => a.partial_cmp(b),
254        (Value::E18s(a), Value::E18s(b)) => a.partial_cmp(b),
255        (Value::Enum(a), Value::Enum(b)) => a.partial_cmp(b),
256        (Value::Float32(a), Value::Float32(b)) => a.partial_cmp(b),
257        (Value::Float64(a), Value::Float64(b)) => a.partial_cmp(b),
258        (Value::Int(a), Value::Int(b)) => a.partial_cmp(b),
259        (Value::Int128(a), Value::Int128(b)) => a.partial_cmp(b),
260        (Value::IntBig(a), Value::IntBig(b)) => a.partial_cmp(b),
261        (Value::Principal(a), Value::Principal(b)) => a.partial_cmp(b),
262        (Value::Subaccount(a), Value::Subaccount(b)) => a.partial_cmp(b),
263        (Value::Text(a), Value::Text(b)) => a.partial_cmp(b),
264        (Value::Timestamp(a), Value::Timestamp(b)) => a.partial_cmp(b),
265        (Value::Uint(a), Value::Uint(b)) => a.partial_cmp(b),
266        (Value::Uint128(a), Value::Uint128(b)) => a.partial_cmp(b),
267        (Value::UintBig(a), Value::UintBig(b)) => a.partial_cmp(b),
268        (Value::Ulid(a), Value::Ulid(b)) => a.partial_cmp(b),
269        (Value::Unit, Value::Unit) => Some(Ordering::Equal),
270        _ => None,
271    }
272}
273
274/// Normalize identifier/text comparisons by parsing textual
275/// representations into identifier values when possible.
276fn coerce_identifier_text(left: &Value, right: &Value) -> Option<(Value, Value)> {
277    match (left, right) {
278        (Value::Ulid(_) | Value::Principal(_) | Value::Account(_), Value::Text(_)) => {
279            let parsed = parse_identifier_text(left, right)?;
280            Some((left.clone(), parsed))
281        }
282        (Value::Text(_), Value::Ulid(_) | Value::Principal(_) | Value::Account(_)) => {
283            let parsed = parse_identifier_text(right, left)?;
284            Some((parsed, right.clone()))
285        }
286        _ => None,
287    }
288}
289
290fn parse_identifier_text(identifier: &Value, text: &Value) -> Option<Value> {
291    let Value::Text(raw) = text else {
292        return None;
293    };
294
295    match identifier {
296        Value::Ulid(_) => Ulid::from_str(raw).ok().map(Value::Ulid),
297        Value::Principal(_) => Principal::from_str(raw).ok().map(Value::Principal),
298        Value::Account(_) => Account::from_str(raw).ok().map(Value::Account),
299        _ => None,
300    }
301}
302
303fn compare_casefold(left: &Value, right: &Value) -> Option<bool> {
304    let left = casefold_value(left)?;
305    let right = casefold_value(right)?;
306    Some(left == right)
307}
308
309/// Convert a value to its casefolded textual representation,
310/// if supported.
311fn casefold_value(value: &Value) -> Option<String> {
312    match value {
313        Value::Text(text) => Some(casefold(text)),
314        Value::Ulid(ulid) => Some(casefold(&ulid.to_string())),
315        Value::Principal(principal) => Some(casefold(&principal.to_string())),
316        Value::Account(account) => Some(casefold(&account.to_string())),
317        _ => None,
318    }
319}
320
321fn casefold(input: &str) -> String {
322    if input.is_ascii() {
323        return input.to_ascii_lowercase();
324    }
325
326    // Unicode fallback; matches Value::text_* casefold behavior.
327    input.to_lowercase()
328}