Skip to main content

icydb_core/model/
field.rs

1//! Module: model::field
2//! Responsibility: runtime field metadata and storage-decode contracts.
3//! Does not own: planner-wide query semantics or row-container orchestration.
4//! Boundary: field-level runtime schema surface used by storage and planning layers.
5
6use crate::{
7    traits::RuntimeValueKind,
8    types::{Decimal, EntityTag},
9    value::Value,
10};
11use std::{borrow::Cow, cmp::Ordering};
12
13///
14/// FieldStorageDecode
15///
16/// FieldStorageDecode captures how one persisted field payload must be
17/// interpreted at structural decode boundaries.
18/// Semantic `FieldKind` alone is not always authoritative for persisted decode:
19/// some fields intentionally store raw `Value` payloads even when their planner
20/// shape is narrower.
21///
22
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24pub enum FieldStorageDecode {
25    /// Decode the persisted field payload according to semantic `FieldKind`.
26    ByKind,
27    /// Decode the persisted field payload directly into `Value`.
28    Value,
29}
30
31///
32/// ScalarCodec
33///
34/// ScalarCodec identifies the canonical binary leaf encoding used for one
35/// scalar persisted field payload.
36/// These codecs are fixed-width or span-bounded by the surrounding row slot
37/// container; they do not perform map/array/value dispatch.
38///
39
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub enum ScalarCodec {
42    Blob,
43    Bool,
44    Date,
45    Duration,
46    Float32,
47    Float64,
48    Int64,
49    Principal,
50    Subaccount,
51    Text,
52    Timestamp,
53    Uint64,
54    Ulid,
55    Unit,
56}
57
58///
59/// LeafCodec
60///
61/// LeafCodec declares whether one persisted field payload uses a dedicated
62/// scalar codec or falls back to structural leaf decoding.
63/// The row container consults this metadata before deciding whether a slot can
64/// stay on the scalar fast path.
65///
66
67#[derive(Clone, Copy, Debug, Eq, PartialEq)]
68pub enum LeafCodec {
69    Scalar(ScalarCodec),
70    StructuralFallback,
71}
72
73///
74/// EnumVariantModel
75///
76/// EnumVariantModel carries structural decode metadata for one generated enum
77/// variant payload.
78/// Runtime structural decode uses this to stay on the field-kind contract for
79/// enum payloads instead of falling back to generic untyped structural decode.
80///
81
82#[derive(Clone, Copy, Debug)]
83pub struct EnumVariantModel {
84    /// Stable schema variant tag.
85    pub(crate) ident: &'static str,
86    /// Declared payload kind when this variant carries data.
87    pub(crate) payload_kind: Option<&'static FieldKind>,
88    /// Persisted payload decode contract for the carried data.
89    pub(crate) payload_storage_decode: FieldStorageDecode,
90}
91
92impl EnumVariantModel {
93    /// Build one enum variant structural decode descriptor.
94    #[must_use]
95    pub const fn new(
96        ident: &'static str,
97        payload_kind: Option<&'static FieldKind>,
98        payload_storage_decode: FieldStorageDecode,
99    ) -> Self {
100        Self {
101            ident,
102            payload_kind,
103            payload_storage_decode,
104        }
105    }
106
107    /// Return the stable schema variant tag.
108    #[must_use]
109    pub const fn ident(&self) -> &'static str {
110        self.ident
111    }
112
113    /// Return the declared payload kind when this variant carries data.
114    #[must_use]
115    pub const fn payload_kind(&self) -> Option<&'static FieldKind> {
116        self.payload_kind
117    }
118
119    /// Return the persisted payload decode contract for this variant.
120    #[must_use]
121    pub const fn payload_storage_decode(&self) -> FieldStorageDecode {
122        self.payload_storage_decode
123    }
124}
125
126///
127/// FieldModel
128///
129/// Runtime field metadata surfaced by macro-generated `EntityModel` values.
130///
131/// This is the smallest unit consumed by predicate validation, planning,
132/// and executor-side plan checks.
133///
134
135#[derive(Debug)]
136pub struct FieldModel {
137    /// Field name as used in predicates and indexing.
138    pub(crate) name: &'static str,
139    /// Runtime type shape (no schema-layer graph nodes).
140    pub(crate) kind: FieldKind,
141    /// Whether the field may persist an explicit `NULL` payload.
142    pub(crate) nullable: bool,
143    /// Persisted field decode contract used by structural runtime decoders.
144    pub(crate) storage_decode: FieldStorageDecode,
145    /// Leaf payload codec used by slot readers and writers.
146    pub(crate) leaf_codec: LeafCodec,
147    /// Insert-time generation contract admitted on reduced SQL write lanes.
148    pub(crate) insert_generation: Option<FieldInsertGeneration>,
149    /// Auto-managed write contract emitted for derive-owned system fields.
150    pub(crate) write_management: Option<FieldWriteManagement>,
151}
152
153///
154/// FieldInsertGeneration
155///
156/// FieldInsertGeneration declares whether one runtime field may be synthesized
157/// by the reduced SQL insert boundary when the user omits that field.
158/// This stays separate from typed-Rust `Default` behavior so write-time
159/// generation remains an explicit schema contract.
160///
161
162#[derive(Clone, Copy, Debug, Eq, PartialEq)]
163pub enum FieldInsertGeneration {
164    /// Generate one fresh `Ulid` value at insert time.
165    Ulid,
166    /// Generate one current wall-clock `Timestamp` value at insert time.
167    Timestamp,
168}
169
170///
171/// FieldWriteManagement
172///
173/// FieldWriteManagement declares whether one runtime field is owned by the
174/// write boundary during insert or update synthesis.
175/// This keeps auto-managed system fields explicit in schema/runtime metadata
176/// instead of relying on literal field names in write paths.
177///
178
179#[derive(Clone, Copy, Debug, Eq, PartialEq)]
180pub enum FieldWriteManagement {
181    /// Fill only on insert when the row is first created.
182    CreatedAt,
183    /// Refresh on insert and every update.
184    UpdatedAt,
185}
186
187impl FieldModel {
188    /// Build one generated runtime field descriptor.
189    ///
190    /// This constructor exists for derive/codegen output and trusted test
191    /// fixtures. Runtime planning and execution treat `FieldModel` values as
192    /// build-time-validated metadata.
193    #[must_use]
194    #[doc(hidden)]
195    pub const fn generated(name: &'static str, kind: FieldKind) -> Self {
196        Self::generated_with_storage_decode_and_nullability(
197            name,
198            kind,
199            FieldStorageDecode::ByKind,
200            false,
201        )
202    }
203
204    /// Build one runtime field descriptor with an explicit persisted decode contract.
205    #[must_use]
206    #[doc(hidden)]
207    pub const fn generated_with_storage_decode(
208        name: &'static str,
209        kind: FieldKind,
210        storage_decode: FieldStorageDecode,
211    ) -> Self {
212        Self::generated_with_storage_decode_and_nullability(name, kind, storage_decode, false)
213    }
214
215    /// Build one runtime field descriptor with an explicit decode contract and nullability.
216    #[must_use]
217    #[doc(hidden)]
218    pub const fn generated_with_storage_decode_and_nullability(
219        name: &'static str,
220        kind: FieldKind,
221        storage_decode: FieldStorageDecode,
222        nullable: bool,
223    ) -> Self {
224        Self::generated_with_storage_decode_nullability_and_write_policies(
225            name,
226            kind,
227            storage_decode,
228            nullable,
229            None,
230            None,
231        )
232    }
233
234    /// Build one runtime field descriptor with an explicit decode contract, nullability,
235    /// and insert-time generation contract.
236    #[must_use]
237    #[doc(hidden)]
238    pub const fn generated_with_storage_decode_nullability_and_insert_generation(
239        name: &'static str,
240        kind: FieldKind,
241        storage_decode: FieldStorageDecode,
242        nullable: bool,
243        insert_generation: Option<FieldInsertGeneration>,
244    ) -> Self {
245        Self::generated_with_storage_decode_nullability_and_write_policies(
246            name,
247            kind,
248            storage_decode,
249            nullable,
250            insert_generation,
251            None,
252        )
253    }
254
255    /// Build one runtime field descriptor with explicit insert-generation and
256    /// write-management policies.
257    #[must_use]
258    #[doc(hidden)]
259    pub const fn generated_with_storage_decode_nullability_and_write_policies(
260        name: &'static str,
261        kind: FieldKind,
262        storage_decode: FieldStorageDecode,
263        nullable: bool,
264        insert_generation: Option<FieldInsertGeneration>,
265        write_management: Option<FieldWriteManagement>,
266    ) -> Self {
267        Self {
268            name,
269            kind,
270            nullable,
271            storage_decode,
272            leaf_codec: leaf_codec_for(kind, storage_decode),
273            insert_generation,
274            write_management,
275        }
276    }
277
278    /// Return the stable field name.
279    #[must_use]
280    pub const fn name(&self) -> &'static str {
281        self.name
282    }
283
284    /// Return the runtime type-kind descriptor.
285    #[must_use]
286    pub const fn kind(&self) -> FieldKind {
287        self.kind
288    }
289
290    /// Return whether the persisted field contract permits explicit `NULL`.
291    #[must_use]
292    pub const fn nullable(&self) -> bool {
293        self.nullable
294    }
295
296    /// Return the persisted field decode contract.
297    #[must_use]
298    pub const fn storage_decode(&self) -> FieldStorageDecode {
299        self.storage_decode
300    }
301
302    /// Return the persisted leaf payload codec.
303    #[must_use]
304    pub const fn leaf_codec(&self) -> LeafCodec {
305        self.leaf_codec
306    }
307
308    /// Return the reduced-SQL insert-time generation contract for this field.
309    #[must_use]
310    pub const fn insert_generation(&self) -> Option<FieldInsertGeneration> {
311        self.insert_generation
312    }
313
314    /// Return the write-boundary management contract for this field.
315    #[must_use]
316    pub const fn write_management(&self) -> Option<FieldWriteManagement> {
317        self.write_management
318    }
319
320    /// Validate one runtime value against this field's persisted storage contract.
321    ///
322    /// This is the model-owned compatibility gate used before row bytes are
323    /// emitted. It intentionally checks storage compatibility, not query
324    /// predicate compatibility, so `FieldStorageDecode::Value` can accept
325    /// open-ended structured payloads while still enforcing outer collection
326    /// shape, decimal scale, and deterministic set/map ordering.
327    pub(crate) fn validate_runtime_value_for_storage(&self, value: &Value) -> Result<(), String> {
328        if matches!(value, Value::Null) {
329            if self.nullable() {
330                return Ok(());
331            }
332
333            return Err("required field cannot store null".into());
334        }
335
336        let accepts = match self.storage_decode() {
337            FieldStorageDecode::Value => {
338                value_storage_kind_accepts_runtime_value(self.kind(), value)
339            }
340            FieldStorageDecode::ByKind => {
341                by_kind_storage_kind_accepts_runtime_value(self.kind(), value)
342            }
343        };
344        if !accepts {
345            return Err(format!(
346                "field kind {:?} does not accept runtime value {value:?}",
347                self.kind()
348            ));
349        }
350
351        ensure_decimal_scale_matches(self.kind(), value)?;
352        ensure_text_max_len_matches(self.kind(), value)?;
353        ensure_value_is_deterministic_for_storage(self.kind(), value)
354    }
355
356    // Normalize decimal payloads to this field's fixed scale before storage
357    // encoding. Validation still runs after this step, so malformed shapes and
358    // deterministic collection rules remain owned by the normal field contract.
359    pub(crate) fn normalize_runtime_value_for_storage<'a>(
360        &self,
361        value: &'a Value,
362    ) -> Result<Cow<'a, Value>, String> {
363        normalize_decimal_scale_for_storage(self.kind(), value)
364    }
365}
366
367// Resolve the canonical leaf codec from semantic field kind plus storage
368// contract. Fields that intentionally persist as `Value` or that still require
369// recursive payload decoding remain on the shared structural fallback.
370const fn leaf_codec_for(kind: FieldKind, storage_decode: FieldStorageDecode) -> LeafCodec {
371    if matches!(storage_decode, FieldStorageDecode::Value) {
372        return LeafCodec::StructuralFallback;
373    }
374
375    match kind {
376        FieldKind::Blob => LeafCodec::Scalar(ScalarCodec::Blob),
377        FieldKind::Bool => LeafCodec::Scalar(ScalarCodec::Bool),
378        FieldKind::Date => LeafCodec::Scalar(ScalarCodec::Date),
379        FieldKind::Duration => LeafCodec::Scalar(ScalarCodec::Duration),
380        FieldKind::Float32 => LeafCodec::Scalar(ScalarCodec::Float32),
381        FieldKind::Float64 => LeafCodec::Scalar(ScalarCodec::Float64),
382        FieldKind::Int => LeafCodec::Scalar(ScalarCodec::Int64),
383        FieldKind::Principal => LeafCodec::Scalar(ScalarCodec::Principal),
384        FieldKind::Subaccount => LeafCodec::Scalar(ScalarCodec::Subaccount),
385        FieldKind::Text { .. } => LeafCodec::Scalar(ScalarCodec::Text),
386        FieldKind::Timestamp => LeafCodec::Scalar(ScalarCodec::Timestamp),
387        FieldKind::Uint => LeafCodec::Scalar(ScalarCodec::Uint64),
388        FieldKind::Ulid => LeafCodec::Scalar(ScalarCodec::Ulid),
389        FieldKind::Unit => LeafCodec::Scalar(ScalarCodec::Unit),
390        FieldKind::Relation { key_kind, .. } => leaf_codec_for(*key_kind, storage_decode),
391        FieldKind::Account
392        | FieldKind::Decimal { .. }
393        | FieldKind::Enum { .. }
394        | FieldKind::Int128
395        | FieldKind::IntBig
396        | FieldKind::List(_)
397        | FieldKind::Map { .. }
398        | FieldKind::Set(_)
399        | FieldKind::Structured { .. }
400        | FieldKind::Uint128
401        | FieldKind::UintBig => LeafCodec::StructuralFallback,
402    }
403}
404
405///
406/// RelationStrength
407///
408/// Explicit relation intent for save-time referential integrity.
409///
410
411#[derive(Clone, Copy, Debug, Eq, PartialEq)]
412pub enum RelationStrength {
413    Strong,
414    Weak,
415}
416
417///
418/// FieldKind
419///
420/// Minimal runtime type surface needed by planning, validation, and execution.
421///
422/// This is aligned with `Value` variants and intentionally lossy: it encodes
423/// only the shape required for predicate compatibility and index planning.
424///
425
426#[derive(Clone, Copy, Debug)]
427pub enum FieldKind {
428    // Scalar primitives
429    Account,
430    Blob,
431    Bool,
432    Date,
433    Decimal {
434        /// Required schema-declared fractional scale for decimal fields.
435        scale: u32,
436    },
437    Duration,
438    Enum {
439        /// Fully-qualified enum type path used for strict filter normalization.
440        path: &'static str,
441        /// Declared per-variant payload decode metadata.
442        variants: &'static [EnumVariantModel],
443    },
444    Float32,
445    Float64,
446    Int,
447    Int128,
448    IntBig,
449    Principal,
450    Subaccount,
451    Text {
452        /// Optional schema-declared maximum Unicode scalar count for text fields.
453        max_len: Option<u32>,
454    },
455    Timestamp,
456    Uint,
457    Uint128,
458    UintBig,
459    Ulid,
460    Unit,
461
462    /// Typed relation; `key_kind` reflects the referenced key type.
463    /// `strength` encodes strong vs. weak relation intent.
464    Relation {
465        /// Fully-qualified Rust type path for diagnostics.
466        target_path: &'static str,
467        /// Stable external name used in storage keys.
468        target_entity_name: &'static str,
469        /// Stable runtime identity used on hot execution paths.
470        target_entity_tag: EntityTag,
471        /// Data store path where the target entity is persisted.
472        target_store_path: &'static str,
473        key_kind: &'static Self,
474        strength: RelationStrength,
475    },
476
477    // Collections
478    List(&'static Self),
479    Set(&'static Self),
480    /// Deterministic, unordered key/value collection.
481    ///
482    /// Map fields are persistable and patchable, but not queryable or indexable.
483    Map {
484        key: &'static Self,
485        value: &'static Self,
486    },
487
488    /// Structured (non-atomic) value.
489    /// Queryability here controls whether predicates may target this field,
490    /// not whether it may be stored or updated.
491    Structured {
492        queryable: bool,
493    },
494}
495
496impl FieldKind {
497    #[must_use]
498    pub const fn value_kind(&self) -> RuntimeValueKind {
499        match self {
500            Self::Account
501            | Self::Blob
502            | Self::Bool
503            | Self::Date
504            | Self::Duration
505            | Self::Enum { .. }
506            | Self::Float32
507            | Self::Float64
508            | Self::Int
509            | Self::Int128
510            | Self::IntBig
511            | Self::Principal
512            | Self::Subaccount
513            | Self::Text { .. }
514            | Self::Timestamp
515            | Self::Uint
516            | Self::Uint128
517            | Self::UintBig
518            | Self::Ulid
519            | Self::Unit
520            | Self::Decimal { .. }
521            | Self::Relation { .. } => RuntimeValueKind::Atomic,
522            Self::List(_) | Self::Set(_) => RuntimeValueKind::Structured { queryable: true },
523            Self::Map { .. } => RuntimeValueKind::Structured { queryable: false },
524            Self::Structured { queryable } => RuntimeValueKind::Structured {
525                queryable: *queryable,
526            },
527        }
528    }
529
530    /// Returns `true` if this field shape is permitted in
531    /// persisted or query-visible schemas under the current
532    /// determinism policy.
533    ///
534    /// This shape-level check is structural only; query-time policy
535    /// enforcement (for example, map predicate fencing) is applied at
536    /// query construction and validation boundaries.
537    #[must_use]
538    pub const fn is_deterministic_collection_shape(&self) -> bool {
539        match self {
540            Self::Relation { key_kind, .. } => key_kind.is_deterministic_collection_shape(),
541
542            Self::List(inner) | Self::Set(inner) => inner.is_deterministic_collection_shape(),
543
544            Self::Map { key, value } => {
545                key.is_deterministic_collection_shape() && value.is_deterministic_collection_shape()
546            }
547
548            _ => true,
549        }
550    }
551
552    /// Return true when this planner-frozen grouped field kind can stay on the
553    /// borrowed grouped-key probe path without owned canonical materialization.
554    #[must_use]
555    pub(crate) fn supports_group_probe(&self) -> bool {
556        match self {
557            Self::Enum { variants, .. } => variants.iter().all(|variant| {
558                variant
559                    .payload_kind()
560                    .is_none_or(Self::supports_group_probe)
561            }),
562            Self::Relation { key_kind, .. } => key_kind.supports_group_probe(),
563            Self::List(_)
564            | Self::Set(_)
565            | Self::Map { .. }
566            | Self::Structured { .. }
567            | Self::Unit => false,
568            Self::Account
569            | Self::Blob
570            | Self::Bool
571            | Self::Date
572            | Self::Decimal { .. }
573            | Self::Duration
574            | Self::Float32
575            | Self::Float64
576            | Self::Int
577            | Self::Int128
578            | Self::IntBig
579            | Self::Principal
580            | Self::Subaccount
581            | Self::Text { .. }
582            | Self::Timestamp
583            | Self::Uint
584            | Self::Uint128
585            | Self::UintBig
586            | Self::Ulid => true,
587        }
588    }
589
590    /// Match one runtime value against this field kind contract.
591    ///
592    /// This is the shared recursive field-kind acceptance boundary used by
593    /// persisted-row encoding, mutation-save validation, and aggregate field
594    /// extraction.
595    #[must_use]
596    pub(crate) fn accepts_value(&self, value: &Value) -> bool {
597        match (self, value) {
598            (Self::Account, Value::Account(_))
599            | (Self::Blob, Value::Blob(_))
600            | (Self::Bool, Value::Bool(_))
601            | (Self::Date, Value::Date(_))
602            | (Self::Decimal { .. }, Value::Decimal(_))
603            | (Self::Duration, Value::Duration(_))
604            | (Self::Enum { .. }, Value::Enum(_))
605            | (Self::Float32, Value::Float32(_))
606            | (Self::Float64, Value::Float64(_))
607            | (Self::Int, Value::Int(_))
608            | (Self::Int128, Value::Int128(_))
609            | (Self::IntBig, Value::IntBig(_))
610            | (Self::Principal, Value::Principal(_))
611            | (Self::Subaccount, Value::Subaccount(_))
612            | (Self::Text { .. }, Value::Text(_))
613            | (Self::Timestamp, Value::Timestamp(_))
614            | (Self::Uint, Value::Uint(_))
615            | (Self::Uint128, Value::Uint128(_))
616            | (Self::UintBig, Value::UintBig(_))
617            | (Self::Ulid, Value::Ulid(_))
618            | (Self::Unit, Value::Unit)
619            | (Self::Structured { .. }, Value::List(_) | Value::Map(_)) => true,
620            (Self::Relation { key_kind, .. }, value) => key_kind.accepts_value(value),
621            (Self::List(inner) | Self::Set(inner), Value::List(items)) => {
622                items.iter().all(|item| inner.accepts_value(item))
623            }
624            (Self::Map { key, value }, Value::Map(entries)) => {
625                if Value::validate_map_entries(entries.as_slice()).is_err() {
626                    return false;
627                }
628
629                entries.iter().all(|(entry_key, entry_value)| {
630                    key.accepts_value(entry_key) && value.accepts_value(entry_value)
631                })
632            }
633            _ => false,
634        }
635    }
636}
637
638// `FieldStorageDecode::ByKind` follows the same literal compatibility rule as
639// the schema predicate layer without routing the storage model through
640// `db::schema`. Structured field kinds are intentionally not accepted here;
641// fields that persist open-ended structured payloads use
642// `FieldStorageDecode::Value` instead.
643fn by_kind_storage_kind_accepts_runtime_value(kind: FieldKind, value: &Value) -> bool {
644    match (kind, value) {
645        (FieldKind::Relation { key_kind, .. }, value) => {
646            by_kind_storage_kind_accepts_runtime_value(*key_kind, value)
647        }
648        (FieldKind::List(inner) | FieldKind::Set(inner), Value::List(items)) => items
649            .iter()
650            .all(|item| by_kind_storage_kind_accepts_runtime_value(*inner, item)),
651        (
652            FieldKind::Map {
653                key,
654                value: value_kind,
655            },
656            Value::Map(entries),
657        ) => {
658            if Value::validate_map_entries(entries.as_slice()).is_err() {
659                return false;
660            }
661
662            entries.iter().all(|(entry_key, entry_value)| {
663                by_kind_storage_kind_accepts_runtime_value(*key, entry_key)
664                    && by_kind_storage_kind_accepts_runtime_value(*value_kind, entry_value)
665            })
666        }
667        (FieldKind::Structured { .. }, _) => false,
668        _ => kind.accepts_value(value),
669    }
670}
671
672// `FieldStorageDecode::Value` fields persist an opaque runtime `Value` envelope,
673// so `FieldKind::Structured` must stay open-ended while outer collection/map
674// shapes still enforce the recursive structure the model owns.
675fn value_storage_kind_accepts_runtime_value(kind: FieldKind, value: &Value) -> bool {
676    match (kind, value) {
677        (FieldKind::Structured { .. }, _) => true,
678        (FieldKind::Relation { key_kind, .. }, value) => {
679            value_storage_kind_accepts_runtime_value(*key_kind, value)
680        }
681        (FieldKind::List(inner) | FieldKind::Set(inner), Value::List(items)) => items
682            .iter()
683            .all(|item| value_storage_kind_accepts_runtime_value(*inner, item)),
684        (
685            FieldKind::Map {
686                key,
687                value: value_kind,
688            },
689            Value::Map(entries),
690        ) => {
691            if Value::validate_map_entries(entries.as_slice()).is_err() {
692                return false;
693            }
694
695            entries.iter().all(|(entry_key, entry_value)| {
696                value_storage_kind_accepts_runtime_value(*key, entry_key)
697                    && value_storage_kind_accepts_runtime_value(*value_kind, entry_value)
698            })
699        }
700        _ => kind.accepts_value(value),
701    }
702}
703
704// Enforce fixed decimal scales through nested collection/map shapes before a
705// field-level runtime value is persisted.
706fn ensure_decimal_scale_matches(kind: FieldKind, value: &Value) -> Result<(), String> {
707    if matches!(value, Value::Null) {
708        return Ok(());
709    }
710
711    match (kind, value) {
712        (FieldKind::Decimal { scale }, Value::Decimal(decimal)) => {
713            if decimal.scale() != scale {
714                return Err(format!(
715                    "decimal scale mismatch: expected {scale}, found {}",
716                    decimal.scale()
717                ));
718            }
719
720            Ok(())
721        }
722        (FieldKind::Relation { key_kind, .. }, value) => {
723            ensure_decimal_scale_matches(*key_kind, value)
724        }
725        (FieldKind::List(inner) | FieldKind::Set(inner), Value::List(items)) => {
726            for item in items {
727                ensure_decimal_scale_matches(*inner, item)?;
728            }
729
730            Ok(())
731        }
732        (
733            FieldKind::Map {
734                key,
735                value: map_value,
736            },
737            Value::Map(entries),
738        ) => {
739            for (entry_key, entry_value) in entries {
740                ensure_decimal_scale_matches(*key, entry_key)?;
741                ensure_decimal_scale_matches(*map_value, entry_value)?;
742            }
743
744            Ok(())
745        }
746        _ => Ok(()),
747    }
748}
749
750// Normalize fixed-scale decimal values through nested collection/map shapes
751// before the field-level payload is encoded. This is write-side canonicalization;
752// callers that validate already persisted bytes still use the exact scale check.
753fn normalize_decimal_scale_for_storage(
754    kind: FieldKind,
755    value: &Value,
756) -> Result<Cow<'_, Value>, String> {
757    if matches!(value, Value::Null) {
758        return Ok(Cow::Borrowed(value));
759    }
760
761    match (kind, value) {
762        (FieldKind::Decimal { scale }, Value::Decimal(decimal)) => {
763            let normalized = decimal_with_storage_scale(*decimal, scale).ok_or_else(|| {
764                format!(
765                    "decimal scale mismatch: expected {scale}, found {}",
766                    decimal.scale()
767                )
768            })?;
769
770            if normalized.scale() == decimal.scale() {
771                Ok(Cow::Borrowed(value))
772            } else {
773                Ok(Cow::Owned(Value::Decimal(normalized)))
774            }
775        }
776        (FieldKind::Relation { key_kind, .. }, value) => {
777            normalize_decimal_scale_for_storage(*key_kind, value)
778        }
779        (FieldKind::List(inner) | FieldKind::Set(inner), Value::List(items)) => {
780            normalize_decimal_list_items(*inner, items.as_slice()).map(|items| {
781                items.map_or_else(
782                    || Cow::Borrowed(value),
783                    |items| Cow::Owned(Value::List(items)),
784                )
785            })
786        }
787        (
788            FieldKind::Map {
789                key,
790                value: map_value,
791            },
792            Value::Map(entries),
793        ) => normalize_decimal_map_entries(*key, *map_value, entries.as_slice()).map(|entries| {
794            entries.map_or_else(
795                || Cow::Borrowed(value),
796                |entries| Cow::Owned(Value::Map(entries)),
797            )
798        }),
799        _ => Ok(Cow::Borrowed(value)),
800    }
801}
802
803// Convert one decimal into the exact storage scale. Lower-scale values are
804// padded without changing their numeric value; higher-scale values use the same
805// round-half-away-from-zero policy as SQL/fluent decimal rounding.
806fn decimal_with_storage_scale(decimal: Decimal, scale: u32) -> Option<Decimal> {
807    match decimal.scale().cmp(&scale) {
808        Ordering::Equal => Some(decimal),
809        Ordering::Less => decimal
810            .scale_to_integer(scale)
811            .map(|mantissa| Decimal::from_i128_with_scale(mantissa, scale)),
812        Ordering::Greater => Some(decimal.round_dp(scale)),
813    }
814}
815
816// Normalize decimal items while preserving the original list allocation when
817// every item is already canonical for its nested field kind.
818fn normalize_decimal_list_items(
819    kind: FieldKind,
820    items: &[Value],
821) -> Result<Option<Vec<Value>>, String> {
822    let mut normalized_items = None;
823
824    for (index, item) in items.iter().enumerate() {
825        let normalized = normalize_decimal_scale_for_storage(kind, item)?;
826        if let Cow::Owned(value) = normalized {
827            let items = normalized_items.get_or_insert_with(|| items.to_vec());
828            items[index] = value;
829        }
830    }
831
832    Ok(normalized_items)
833}
834
835// Normalize decimal keys and values while preserving the original map
836// allocation when every entry is already canonical for its nested field kind.
837fn normalize_decimal_map_entries(
838    key_kind: FieldKind,
839    value_kind: FieldKind,
840    entries: &[(Value, Value)],
841) -> Result<Option<Vec<(Value, Value)>>, String> {
842    let mut normalized_entries = None;
843
844    for (index, (entry_key, entry_value)) in entries.iter().enumerate() {
845        let normalized_key = normalize_decimal_scale_for_storage(key_kind, entry_key)?;
846        let normalized_value = normalize_decimal_scale_for_storage(value_kind, entry_value)?;
847
848        if matches!(normalized_key, Cow::Owned(_)) || matches!(normalized_value, Cow::Owned(_)) {
849            let entries = normalized_entries.get_or_insert_with(|| entries.to_vec());
850            if let Cow::Owned(value) = normalized_key {
851                entries[index].0 = value;
852            }
853            if let Cow::Owned(value) = normalized_value {
854                entries[index].1 = value;
855            }
856        }
857    }
858
859    Ok(normalized_entries)
860}
861
862// Enforce bounded text length through nested collection/map shapes before a
863// field-level runtime value is persisted.
864fn ensure_text_max_len_matches(kind: FieldKind, value: &Value) -> Result<(), String> {
865    if matches!(value, Value::Null) {
866        return Ok(());
867    }
868
869    match (kind, value) {
870        (FieldKind::Text { max_len: Some(max) }, Value::Text(text)) => {
871            let len = text.chars().count();
872            if len > max as usize {
873                return Err(format!(
874                    "text length exceeds max_len: expected at most {max}, found {len}"
875                ));
876            }
877
878            Ok(())
879        }
880        (FieldKind::Relation { key_kind, .. }, value) => {
881            ensure_text_max_len_matches(*key_kind, value)
882        }
883        (FieldKind::List(inner) | FieldKind::Set(inner), Value::List(items)) => {
884            for item in items {
885                ensure_text_max_len_matches(*inner, item)?;
886            }
887
888            Ok(())
889        }
890        (
891            FieldKind::Map {
892                key,
893                value: map_value,
894            },
895            Value::Map(entries),
896        ) => {
897            for (entry_key, entry_value) in entries {
898                ensure_text_max_len_matches(*key, entry_key)?;
899                ensure_text_max_len_matches(*map_value, entry_value)?;
900            }
901
902            Ok(())
903        }
904        _ => Ok(()),
905    }
906}
907
908// Enforce the canonical persisted ordering rules for set/map shapes before one
909// field-level runtime value becomes row bytes.
910fn ensure_value_is_deterministic_for_storage(kind: FieldKind, value: &Value) -> Result<(), String> {
911    match (kind, value) {
912        (FieldKind::Set(_), Value::List(items)) => {
913            for pair in items.windows(2) {
914                let [left, right] = pair else {
915                    continue;
916                };
917                if Value::canonical_cmp(left, right) != Ordering::Less {
918                    return Err("set payload must already be canonical and deduplicated".into());
919                }
920            }
921
922            Ok(())
923        }
924        (FieldKind::Map { .. }, Value::Map(entries)) => {
925            Value::validate_map_entries(entries.as_slice()).map_err(|err| err.to_string())?;
926
927            if !Value::map_entries_are_strictly_canonical(entries.as_slice()) {
928                return Err("map payload must already be canonical and deduplicated".into());
929            }
930
931            Ok(())
932        }
933        _ => Ok(()),
934    }
935}
936
937///
938/// TESTS
939///
940
941#[cfg(test)]
942mod tests {
943    use crate::{
944        model::field::{FieldKind, FieldModel},
945        value::Value,
946    };
947
948    static BOUNDED_TEXT: FieldKind = FieldKind::Text { max_len: Some(3) };
949
950    #[test]
951    fn text_max_len_accepts_unbounded_text() {
952        let field = FieldModel::generated("name", FieldKind::Text { max_len: None });
953
954        assert!(
955            field
956                .validate_runtime_value_for_storage(&Value::Text("Ada Lovelace".into()))
957                .is_ok()
958        );
959    }
960
961    #[test]
962    fn text_max_len_counts_unicode_scalars_not_bytes() {
963        let field = FieldModel::generated("name", BOUNDED_TEXT);
964
965        assert!(
966            field
967                .validate_runtime_value_for_storage(&Value::Text("ééé".into()))
968                .is_ok()
969        );
970        assert!(
971            field
972                .validate_runtime_value_for_storage(&Value::Text("éééé".into()))
973                .is_err()
974        );
975    }
976
977    #[test]
978    fn text_max_len_recurses_through_collections() {
979        static TEXT_LIST: FieldKind = FieldKind::List(&BOUNDED_TEXT);
980        static TEXT_MAP: FieldKind = FieldKind::Map {
981            key: &BOUNDED_TEXT,
982            value: &BOUNDED_TEXT,
983        };
984
985        let list_field = FieldModel::generated("names", TEXT_LIST);
986        let map_field = FieldModel::generated("labels", TEXT_MAP);
987
988        assert!(
989            list_field
990                .validate_runtime_value_for_storage(&Value::List(vec![
991                    Value::Text("Ada".into()),
992                    Value::Text("Bob".into()),
993                ]))
994                .is_ok()
995        );
996        assert!(
997            list_field
998                .validate_runtime_value_for_storage(&Value::List(vec![Value::Text("Grace".into())]))
999                .is_err()
1000        );
1001        assert!(
1002            map_field
1003                .validate_runtime_value_for_storage(&Value::Map(vec![(
1004                    Value::Text("key".into()),
1005                    Value::Text("val".into()),
1006                )]))
1007                .is_ok()
1008        );
1009        assert!(
1010            map_field
1011                .validate_runtime_value_for_storage(&Value::Map(vec![(
1012                    Value::Text("long".into()),
1013                    Value::Text("val".into()),
1014                )]))
1015                .is_err()
1016        );
1017    }
1018}