use crate::{
model::field::FieldKind,
types::{Account, Decimal, Float32, Float64, Int, Int128, Nat, Nat128, Principal, Ulid},
value::{Value, ValueEnum},
};
use std::str::FromStr;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum FieldKindNumericClass {
Signed64,
Unsigned64,
SignedWide,
UnsignedWide,
FloatLike,
DecimalLike,
DurationLike,
TimestampLike,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum FieldKindScalarClass {
Boolean,
Numeric(FieldKindNumericClass),
Text,
OrderedOpaque,
Opaque,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum FieldKindCategory {
Scalar(FieldKindScalarClass),
Relation(FieldKindScalarClass),
Collection,
Structured { queryable: bool },
}
impl FieldKindCategory {
#[must_use]
pub(crate) const fn supports_expr_numeric(self) -> bool {
matches!(self, Self::Scalar(FieldKindScalarClass::Numeric(_)))
}
#[must_use]
pub(crate) const fn supports_aggregate_numeric(self) -> bool {
matches!(
self,
Self::Scalar(FieldKindScalarClass::Numeric(_))
| Self::Relation(FieldKindScalarClass::Numeric(_))
)
}
#[must_use]
pub(crate) const fn supports_aggregate_ordering(self) -> bool {
match self {
Self::Scalar(class) | Self::Relation(class) => scalar_class_supports_ordering(class),
Self::Collection | Self::Structured { .. } => false,
}
}
#[must_use]
pub(crate) const fn supports_predicate_numeric_widen(self) -> bool {
matches!(
self,
Self::Scalar(FieldKindScalarClass::Numeric(
FieldKindNumericClass::Signed64
| FieldKindNumericClass::Unsigned64
| FieldKindNumericClass::FloatLike
| FieldKindNumericClass::DecimalLike,
)) | Self::Relation(FieldKindScalarClass::Numeric(
FieldKindNumericClass::Signed64
| FieldKindNumericClass::Unsigned64
| FieldKindNumericClass::FloatLike
| FieldKindNumericClass::DecimalLike,
))
)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct FieldKindSemantics {
category: FieldKindCategory,
}
impl FieldKindSemantics {
#[must_use]
pub(crate) const fn new(category: FieldKindCategory) -> Self {
Self { category }
}
#[must_use]
pub(crate) const fn category(self) -> FieldKindCategory {
self.category
}
#[must_use]
pub(crate) const fn supports_expr_numeric(self) -> bool {
self.category.supports_expr_numeric()
}
#[must_use]
pub(crate) const fn supports_aggregate_numeric(self) -> bool {
self.category.supports_aggregate_numeric()
}
#[must_use]
pub(crate) const fn supports_aggregate_ordering(self) -> bool {
self.category.supports_aggregate_ordering()
}
#[must_use]
pub(crate) const fn supports_predicate_numeric_widen(self) -> bool {
self.category.supports_predicate_numeric_widen()
}
}
#[must_use]
pub(crate) const fn field_kind_has_identity_group_canonical_form(kind: FieldKind) -> bool {
!matches!(
kind,
FieldKind::Decimal { .. }
| FieldKind::Enum { .. }
| FieldKind::Relation { .. }
| FieldKind::List(_)
| FieldKind::Set(_)
| FieldKind::Map { .. }
| FieldKind::Structured { .. }
| FieldKind::Unit
)
}
#[must_use]
pub(crate) fn canonicalize_grouped_having_numeric_literal_for_field_kind(
field_kind: Option<FieldKind>,
value: &Value,
) -> Option<Value> {
canonicalize_lossless_field_literal_for_kind(field_kind?, value, false)
}
#[must_use]
pub(crate) fn canonicalize_strict_sql_literal_for_kind(
kind: &FieldKind,
value: &Value,
) -> Option<Value> {
canonicalize_strict_sql_literal_for_kind_impl(*kind, value)
}
#[must_use]
pub(crate) fn canonicalize_filter_literal_for_kind(
kind: &FieldKind,
value: &Value,
) -> Option<Value> {
canonicalize_lossless_field_literal_for_kind(*kind, value, true)
}
#[must_use]
pub(crate) const fn classify_field_kind(kind: &FieldKind) -> FieldKindSemantics {
match kind {
FieldKind::Account
| FieldKind::Date
| FieldKind::Principal
| FieldKind::Subaccount
| FieldKind::Ulid
| FieldKind::Unit => FieldKindSemantics::new(FieldKindCategory::Scalar(
FieldKindScalarClass::OrderedOpaque,
)),
FieldKind::Blob => {
FieldKindSemantics::new(FieldKindCategory::Scalar(FieldKindScalarClass::Opaque))
}
FieldKind::Bool => {
FieldKindSemantics::new(FieldKindCategory::Scalar(FieldKindScalarClass::Boolean))
}
FieldKind::Decimal { .. } => FieldKindSemantics::new(FieldKindCategory::Scalar(
FieldKindScalarClass::Numeric(FieldKindNumericClass::DecimalLike),
)),
FieldKind::Duration => FieldKindSemantics::new(FieldKindCategory::Scalar(
FieldKindScalarClass::Numeric(FieldKindNumericClass::DurationLike),
)),
FieldKind::Int => FieldKindSemantics::new(FieldKindCategory::Scalar(
FieldKindScalarClass::Numeric(FieldKindNumericClass::Signed64),
)),
FieldKind::Int128 | FieldKind::IntBig => {
FieldKindSemantics::new(FieldKindCategory::Scalar(FieldKindScalarClass::Numeric(
FieldKindNumericClass::SignedWide,
)))
}
FieldKind::Timestamp => FieldKindSemantics::new(FieldKindCategory::Scalar(
FieldKindScalarClass::Numeric(FieldKindNumericClass::TimestampLike),
)),
FieldKind::Uint => FieldKindSemantics::new(FieldKindCategory::Scalar(
FieldKindScalarClass::Numeric(FieldKindNumericClass::Unsigned64),
)),
FieldKind::Uint128 | FieldKind::UintBig => {
FieldKindSemantics::new(FieldKindCategory::Scalar(FieldKindScalarClass::Numeric(
FieldKindNumericClass::UnsignedWide,
)))
}
FieldKind::Enum { .. } | FieldKind::Text { .. } => {
FieldKindSemantics::new(FieldKindCategory::Scalar(FieldKindScalarClass::Text))
}
FieldKind::Float32 | FieldKind::Float64 => {
FieldKindSemantics::new(FieldKindCategory::Scalar(FieldKindScalarClass::Numeric(
FieldKindNumericClass::FloatLike,
)))
}
FieldKind::Relation { key_kind, .. } => FieldKindSemantics::new(
FieldKindCategory::Relation(classify_relation_scalar_class(key_kind)),
),
FieldKind::List(_) | FieldKind::Map { .. } | FieldKind::Set(_) => {
FieldKindSemantics::new(FieldKindCategory::Collection)
}
FieldKind::Structured { queryable } => {
FieldKindSemantics::new(FieldKindCategory::Structured {
queryable: *queryable,
})
}
}
}
const fn classify_relation_scalar_class(kind: &FieldKind) -> FieldKindScalarClass {
match classify_field_kind(kind).category() {
FieldKindCategory::Scalar(class) | FieldKindCategory::Relation(class) => class,
FieldKindCategory::Collection | FieldKindCategory::Structured { .. } => {
FieldKindScalarClass::Opaque
}
}
}
const fn scalar_class_supports_ordering(class: FieldKindScalarClass) -> bool {
!matches!(class, FieldKindScalarClass::Opaque)
}
#[expect(clippy::too_many_lines)]
fn canonicalize_lossless_field_literal_for_kind(
kind: FieldKind,
value: &Value,
allow_text_ulid: bool,
) -> Option<Value> {
match kind {
FieldKind::Account => match value {
Value::Account(inner) => Some(Value::Account(*inner)),
Value::Text(inner) => Account::from_str(inner).ok().map(Value::Account),
_ => None,
},
FieldKind::Bool => match value {
Value::Bool(inner) => Some(Value::Bool(*inner)),
_ => None,
},
FieldKind::Decimal { .. } => match value {
Value::Decimal(inner) => Some(Value::Decimal(*inner)),
Value::Text(inner) => Decimal::from_str(inner).ok().map(Value::Decimal),
_ => None,
},
FieldKind::Enum { .. } => match value {
Value::Enum(inner) => Some(Value::Enum(inner.clone())),
Value::Text(inner) => Some(Value::Enum(ValueEnum::loose(inner))),
_ => None,
},
FieldKind::Float32 => match value {
Value::Float32(inner) => Some(Value::Float32(*inner)),
Value::Text(inner) => inner
.parse::<f32>()
.ok()
.and_then(Float32::try_new)
.map(Value::Float32),
_ => None,
},
FieldKind::Float64 => match value {
Value::Float64(inner) => Some(Value::Float64(*inner)),
Value::Text(inner) => inner
.parse::<f64>()
.ok()
.and_then(Float64::try_new)
.map(Value::Float64),
_ => None,
},
FieldKind::Relation { key_kind, .. } => {
canonicalize_lossless_field_literal_for_kind(*key_kind, value, allow_text_ulid)
}
FieldKind::Int => match value {
Value::Int(inner) => Some(Value::Int(*inner)),
Value::Uint(inner) => i64::try_from(*inner).ok().map(Value::Int),
Value::Text(inner) => inner.parse::<i64>().ok().map(Value::Int),
_ => None,
},
FieldKind::Int128 => match value {
Value::Int128(inner) => Some(Value::Int128(*inner)),
Value::Text(inner) => inner
.parse::<i128>()
.ok()
.map(Int128::from)
.map(Value::Int128),
_ => None,
},
FieldKind::IntBig => match value {
Value::IntBig(inner) => Some(Value::IntBig(inner.clone())),
Value::Text(inner) => Int::from_str(inner).ok().map(Value::IntBig),
_ => None,
},
FieldKind::List(inner) | FieldKind::Set(inner) => match value {
Value::List(values) => Some(Value::List(
values
.iter()
.map(|item| {
canonicalize_lossless_field_literal_for_kind(*inner, item, allow_text_ulid)
.unwrap_or_else(|| item.clone())
})
.collect(),
)),
_ => None,
},
FieldKind::Principal => match value {
Value::Principal(inner) => Some(Value::Principal(*inner)),
Value::Text(inner) => Principal::from_str(inner).ok().map(Value::Principal),
_ => None,
},
FieldKind::Text { .. } => match value {
Value::Text(inner) => Some(Value::Text(inner.clone())),
_ => None,
},
FieldKind::Uint => match value {
Value::Int(inner) => u64::try_from(*inner).ok().map(Value::Uint),
Value::Uint(inner) => Some(Value::Uint(*inner)),
Value::Text(inner) => inner.parse::<u64>().ok().map(Value::Uint),
_ => None,
},
FieldKind::Uint128 => match value {
Value::Uint128(inner) => Some(Value::Uint128(*inner)),
Value::Text(inner) => inner
.parse::<u128>()
.ok()
.map(Nat128::from)
.map(Value::Uint128),
_ => None,
},
FieldKind::UintBig => match value {
Value::UintBig(inner) => Some(Value::UintBig(inner.clone())),
Value::Text(inner) => Nat::from_str(inner).ok().map(Value::UintBig),
_ => None,
},
FieldKind::Unit => match value {
Value::Null | Value::Unit => Some(Value::Unit),
_ => None,
},
FieldKind::Ulid if allow_text_ulid => match value {
Value::Text(inner) => Ulid::from_str(inner).ok().map(Value::Ulid),
Value::Ulid(inner) => Some(Value::Ulid(*inner)),
_ => None,
},
_ => None,
}
}
fn canonicalize_strict_sql_literal_for_kind_impl(kind: FieldKind, value: &Value) -> Option<Value> {
match kind {
FieldKind::Relation { key_kind, .. } => {
canonicalize_strict_sql_literal_for_kind_impl(*key_kind, value)
}
FieldKind::Int => match value {
Value::Uint(inner) => i64::try_from(*inner).ok().map(Value::Int),
_ => None,
},
FieldKind::Uint => match value {
Value::Int(inner) => u64::try_from(*inner).ok().map(Value::Uint),
_ => None,
},
FieldKind::Ulid => match value {
Value::Text(inner) => Ulid::from_str(inner).ok().map(Value::Ulid),
_ => None,
},
FieldKind::List(inner) | FieldKind::Set(inner) => match value {
Value::List(values) => values
.iter()
.map(|item| canonicalize_strict_sql_literal_for_kind_impl(*inner, item))
.collect::<Option<Vec<_>>>()
.map(Value::List),
_ => None,
},
_ => None,
}
}
#[cfg(test)]
mod tests {
use crate::{
model::{
FieldKindCategory, FieldKindNumericClass, FieldKindScalarClass,
canonicalize_grouped_having_numeric_literal_for_field_kind,
canonicalize_strict_sql_literal_for_kind, classify_field_kind, field::FieldKind,
field_kind_has_identity_group_canonical_form,
},
value::Value,
};
#[test]
fn classify_numeric_scalar_field_kind() {
let semantics = classify_field_kind(&FieldKind::Uint);
assert_eq!(
semantics.category(),
FieldKindCategory::Scalar(FieldKindScalarClass::Numeric(
FieldKindNumericClass::Unsigned64,
)),
);
assert!(semantics.supports_expr_numeric());
assert!(semantics.supports_aggregate_numeric());
assert!(semantics.supports_aggregate_ordering());
assert!(semantics.supports_predicate_numeric_widen());
}
#[test]
fn classify_relation_uses_key_semantics_without_expr_numeric() {
static UINT_KEY_KIND: FieldKind = FieldKind::Uint;
static RELATION_KIND: FieldKind = FieldKind::Relation {
target_path: "demo::Target",
target_entity_name: "Target",
target_entity_tag: crate::types::EntityTag::new(1),
target_store_path: "demo::store::TargetStore",
key_kind: &UINT_KEY_KIND,
strength: crate::model::field::RelationStrength::Strong,
};
let semantics = classify_field_kind(&RELATION_KIND);
assert_eq!(
semantics.category(),
FieldKindCategory::Relation(FieldKindScalarClass::Numeric(
FieldKindNumericClass::Unsigned64,
)),
);
assert!(!semantics.supports_expr_numeric());
assert!(semantics.supports_aggregate_numeric());
assert!(semantics.supports_aggregate_ordering());
assert!(semantics.supports_predicate_numeric_widen());
}
#[test]
fn classify_collection_and_blob_stay_non_orderable() {
let collection = classify_field_kind(&FieldKind::List(&FieldKind::Text { max_len: None }));
let blob = classify_field_kind(&FieldKind::Blob);
assert_eq!(collection.category(), FieldKindCategory::Collection);
assert!(!collection.supports_expr_numeric());
assert!(!collection.supports_aggregate_ordering());
assert_eq!(
blob.category(),
FieldKindCategory::Scalar(FieldKindScalarClass::Opaque),
);
assert!(!blob.supports_aggregate_ordering());
}
#[test]
fn classify_wide_integer_and_temporal_kinds_keep_distinct_numeric_facets() {
let wide = classify_field_kind(&FieldKind::Int128);
let duration = classify_field_kind(&FieldKind::Duration);
let timestamp = classify_field_kind(&FieldKind::Timestamp);
assert_eq!(
wide.category(),
FieldKindCategory::Scalar(FieldKindScalarClass::Numeric(
FieldKindNumericClass::SignedWide,
)),
);
assert_eq!(
duration.category(),
FieldKindCategory::Scalar(FieldKindScalarClass::Numeric(
FieldKindNumericClass::DurationLike,
)),
);
assert_eq!(
timestamp.category(),
FieldKindCategory::Scalar(FieldKindScalarClass::Numeric(
FieldKindNumericClass::TimestampLike,
)),
);
assert!(!wide.supports_predicate_numeric_widen());
assert!(!duration.supports_predicate_numeric_widen());
assert!(!timestamp.supports_predicate_numeric_widen());
}
#[test]
fn grouped_field_kind_helpers_keep_decimal_relation_and_unit_edges_explicit() {
static UINT_KEY_KIND: FieldKind = FieldKind::Uint;
static RELATION_KIND: FieldKind = FieldKind::Relation {
target_path: "demo::Target",
target_entity_name: "Target",
target_entity_tag: crate::types::EntityTag::new(1),
target_store_path: "demo::store::TargetStore",
key_kind: &UINT_KEY_KIND,
strength: crate::model::field::RelationStrength::Strong,
};
assert!(field_kind_has_identity_group_canonical_form(
FieldKind::Text { max_len: None }
));
assert!(!field_kind_has_identity_group_canonical_form(
FieldKind::Decimal { scale: 2 }
));
assert!(!field_kind_has_identity_group_canonical_form(RELATION_KIND));
assert!(FieldKind::Decimal { scale: 2 }.supports_group_probe());
assert!(RELATION_KIND.supports_group_probe());
assert!(!FieldKind::Unit.supports_group_probe());
}
#[test]
fn runtime_value_acceptance_recurses_through_nested_field_kinds() {
static TEXT_KIND: FieldKind = FieldKind::Text { max_len: None };
static UINT_KIND: FieldKind = FieldKind::Uint;
static RELATION_KIND: FieldKind = FieldKind::Relation {
target_path: "demo::Target",
target_entity_name: "Target",
target_entity_tag: crate::types::EntityTag::new(1),
target_store_path: "demo::store::TargetStore",
key_kind: &UINT_KIND,
strength: crate::model::field::RelationStrength::Strong,
};
assert!(
FieldKind::Map {
key: &TEXT_KIND,
value: &UINT_KIND,
}
.accepts_value(&Value::Map(vec![(Value::Text("a".into()), Value::Uint(1))]))
);
assert!(RELATION_KIND.accepts_value(&Value::Uint(9)));
assert!(!FieldKind::List(&TEXT_KIND).accepts_value(&Value::List(vec![Value::Uint(1)])));
}
#[test]
fn grouped_having_numeric_canonicalization_keeps_numeric_relation_recursion() {
static UINT_KIND: FieldKind = FieldKind::Uint;
static RELATION_KIND: FieldKind = FieldKind::Relation {
target_path: "demo::Target",
target_entity_name: "Target",
target_entity_tag: crate::types::EntityTag::new(1),
target_store_path: "demo::store::TargetStore",
key_kind: &UINT_KIND,
strength: crate::model::field::RelationStrength::Strong,
};
assert_eq!(
canonicalize_grouped_having_numeric_literal_for_field_kind(
Some(FieldKind::Int),
&Value::Uint(7),
),
Some(Value::Int(7)),
);
assert_eq!(
canonicalize_grouped_having_numeric_literal_for_field_kind(
Some(RELATION_KIND),
&Value::Int(7),
),
Some(Value::Uint(7)),
);
assert_eq!(
canonicalize_grouped_having_numeric_literal_for_field_kind(
Some(FieldKind::Ulid),
&Value::Text("01ARZ3NDEKTSV4RRFFQ69G5FAV".into()),
),
None,
);
}
#[test]
fn strict_sql_literal_canonicalization_adds_ulid_without_widening_other_kinds() {
let ulid_text = "01ARZ3NDEKTSV4RRFFQ69G5FAV".to_string();
assert!(matches!(
canonicalize_strict_sql_literal_for_kind(&FieldKind::Ulid, &Value::Text(ulid_text),),
Some(Value::Ulid(_)),
));
assert_eq!(
canonicalize_strict_sql_literal_for_kind(&FieldKind::Uint, &Value::Int(4)),
Some(Value::Uint(4)),
);
assert_eq!(
canonicalize_strict_sql_literal_for_kind(
&FieldKind::Text { max_len: None },
&Value::Text("x".into())
),
None,
);
}
}