Skip to main content

reddb_engine/catalog/enforcement/
mod.rs

1//! Canonical contract admission. Hosts retain locking, MVCC and durable publication.
2use super::schema::{has_contract_expressions, resolved_contract_columns, ResolvedColumnRule};
3use super::CollectionContract;
4use crate::storage::metadata::MetadataValue;
5use reddb_types::coerce::coerce as coerce_schema_value;
6use reddb_types::{DataType, Value};
7use std::collections::HashMap;
8
9mod expressions;
10
11#[derive(Debug)]
12pub enum ContractError {
13    Query(String),
14    InvalidOperation(String),
15}
16impl std::fmt::Display for ContractError {
17    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        match self {
19            Self::Query(message) => write!(formatter, "query error: {message}"),
20            Self::InvalidOperation(message) => write!(formatter, "INVALID_OPERATION: {message}"),
21        }
22    }
23}
24impl std::error::Error for ContractError {}
25impl From<super::schema::ContractExpressionError> for ContractError {
26    fn from(error: super::schema::ContractExpressionError) -> Self {
27        Self::Query(error.message)
28    }
29}
30type ContractResult<T> = Result<T, ContractError>;
31
32pub const INTERNAL_TTL_SECONDS_KEY: &str = "_ttl";
33pub const INTERNAL_TTL_MILLIS_KEY: &str = "_ttl_ms";
34pub const INTERNAL_EXPIRES_AT_KEY: &str = "_expires_at";
35pub const INTERNAL_TTL_METADATA_KEYS: [&str; 3] = [
36    INTERNAL_TTL_SECONDS_KEY,
37    INTERNAL_TTL_MILLIS_KEY,
38    INTERNAL_EXPIRES_AT_KEY,
39];
40
41pub fn has_internal_ttl_metadata(metadata: &[(String, MetadataValue)]) -> bool {
42    metadata.iter().any(|(key, _)| {
43        INTERNAL_TTL_METADATA_KEYS
44            .iter()
45            .any(|reserved| reserved == &key.as_str())
46    })
47}
48
49#[derive(Debug, Clone)]
50pub struct RowUpdateColumnRule {
51    pub name: String,
52    pub data_type: DataType,
53    pub data_type_name: String,
54    pub not_null: bool,
55    pub enum_variants: Vec<String>,
56}
57
58#[derive(Debug, Clone)]
59pub struct RowUpdateContractPlan {
60    pub has_expressions: bool,
61    pub timestamps_enabled: bool,
62    pub strict_schema: bool,
63    pub declared_rules: HashMap<String, RowUpdateColumnRule>,
64    pub unique_columns: HashMap<String, ()>,
65}
66
67/// Pure value/schema admission. The host supplies authoritative uniqueness
68/// candidates under its MVCC reservation policy and retains the constraint lock
69/// through staging and publication. This evaluator owns no lock or transaction.
70pub struct CollectionContractEnforcer<'a> {
71    contract: &'a CollectionContract,
72}
73
74#[derive(Clone, Copy)]
75pub struct DefaultTtlPlan {
76    pub ttl_ms: Option<u64>,
77}
78
79pub struct ContractRow {
80    pub id: crate::storage::entity::EntityId,
81    pub fields: std::collections::BTreeMap<String, Value>,
82}
83
84impl DefaultTtlPlan {
85    pub fn apply(self, metadata: &mut Vec<(String, MetadataValue)>) {
86        if let Some(entry) = default_ttl_metadata(self.ttl_ms, metadata) {
87            metadata.push(entry);
88        }
89    }
90}
91
92impl<'a> CollectionContractEnforcer<'a> {
93    pub fn new(contract: &'a CollectionContract) -> Self {
94        Self { contract }
95    }
96
97    pub fn normalize_insert_at(
98        &self,
99        fields: Vec<(String, Value)>,
100        now_ms: u64,
101    ) -> ContractResult<Vec<(String, Value)>> {
102        normalize_row_fields_for_contract_at(
103            self.contract,
104            &self.contract.name,
105            fields,
106            NormalizeMode::Insert,
107            now_ms,
108        )
109    }
110
111    pub fn ensure_model(
112        &self,
113        requested_model: crate::catalog::CollectionModel,
114    ) -> ContractResult<()> {
115        if !contract_enforces_model(self.contract)
116            || collection_model_allows(self.contract.declared_model, requested_model)
117        {
118            return Ok(());
119        }
120        if requested_model == crate::catalog::CollectionModel::Vector
121            && self
122                .contract
123                .ai_policy
124                .as_ref()
125                .is_some_and(|policy| policy.embed.is_some() || policy.vision.is_some())
126        {
127            return Ok(());
128        }
129        Err(ContractError::InvalidOperation(format!(
130            "collection '{}' is declared as '{}' and does not allow '{}' writes",
131            self.contract.name,
132            collection_model_name(self.contract.declared_model),
133            collection_model_name(requested_model)
134        )))
135    }
136
137    pub fn ensure_vector_dimension(&self, actual_dimension: usize) -> ContractResult<()> {
138        let Some(expected_dimension) = self.contract.vector_dimension else {
139            return Ok(());
140        };
141        if expected_dimension == actual_dimension {
142            return Ok(());
143        }
144        Err(ContractError::Query(format!(
145            "vector dimension mismatch for collection '{}': expected {expected_dimension}, got {actual_dimension}",
146            self.contract.name
147        )))
148    }
149
150    pub fn normalize_update_at(
151        &self,
152        fields: Vec<(String, Value)>,
153        now_ms: u64,
154    ) -> ContractResult<Vec<(String, Value)>> {
155        normalize_row_fields_for_contract_at(
156            self.contract,
157            &self.contract.name,
158            fields,
159            NormalizeMode::Update,
160            now_ms,
161        )
162    }
163
164    pub fn default_ttl_metadata(
165        &self,
166        metadata: &[(String, MetadataValue)],
167    ) -> Option<(String, MetadataValue)> {
168        default_ttl_metadata(self.contract.default_ttl_ms, metadata)
169    }
170
171    pub fn default_ttl_plan(&self) -> DefaultTtlPlan {
172        DefaultTtlPlan {
173            ttl_ms: self.contract.default_ttl_ms,
174        }
175    }
176
177    pub fn enforce_batch_uniqueness(&self, rows: &[Vec<(String, Value)>]) -> ContractResult<()> {
178        enforce_row_batch_uniqueness_for_contract(self.contract, &self.contract.name, rows)
179    }
180
181    pub fn enforce_uniqueness(
182        &self,
183        fields: &[(String, Value)],
184        existing_rows: &[ContractRow],
185        exclude_id: Option<crate::storage::entity::EntityId>,
186    ) -> ContractResult<()> {
187        enforce_row_uniqueness_for_contract(
188            self.contract,
189            &self.contract.name,
190            fields,
191            existing_rows,
192            exclude_id,
193        )
194    }
195
196    pub fn row_update_plan(&self) -> ContractResult<RowUpdateContractPlan> {
197        build_row_update_contract_plan_for_contract(self.contract)
198    }
199}
200
201fn default_ttl_metadata(
202    default_ttl_ms: Option<u64>,
203    metadata: &[(String, MetadataValue)],
204) -> Option<(String, MetadataValue)> {
205    if has_internal_ttl_metadata(metadata) {
206        return None;
207    }
208
209    default_ttl_ms.map(|ttl| {
210        (
211            "_ttl_ms".to_string(),
212            if ttl <= i64::MAX as u64 {
213                MetadataValue::Int(ttl as i64)
214            } else {
215                MetadataValue::Timestamp(ttl)
216            },
217        )
218    })
219}
220
221#[derive(Clone)]
222pub struct UniquenessRule {
223    pub name: String,
224    pub columns: Vec<String>,
225    pub primary_key: bool,
226}
227
228pub struct UniquenessConflict {
229    pub rule: UniquenessRule,
230    pub entity_id: crate::storage::entity::EntityId,
231}
232
233#[derive(Copy, Clone, PartialEq, Eq)]
234pub enum NormalizeMode {
235    /// First write for this row. Timestamps auto-filled from now on
236    /// both `created_at` and `updated_at`; user attempts to set
237    /// either column are rejected.
238    Insert,
239    /// Update/patch path. `created_at` is preserved from the existing
240    /// row (immutable after insert); `updated_at` is bumped to now.
241    /// User attempts to set either via the patch are rejected.
242    Update,
243}
244
245pub fn contract_enforces_model(contract: &CollectionContract) -> bool {
246    !matches!(
247        (&contract.origin, &contract.schema_mode),
248        (
249            super::ContractOrigin::Implicit,
250            crate::catalog::SchemaMode::Dynamic,
251        )
252    )
253}
254
255pub fn collection_model_allows(
256    declared_model: crate::catalog::CollectionModel,
257    requested_model: crate::catalog::CollectionModel,
258) -> bool {
259    declared_model == requested_model || declared_model == crate::catalog::CollectionModel::Mixed
260}
261
262pub fn collection_model_name(model: crate::catalog::CollectionModel) -> &'static str {
263    match model {
264        crate::catalog::CollectionModel::Table => "table",
265        crate::catalog::CollectionModel::Document => "document",
266        crate::catalog::CollectionModel::Graph => "graph",
267        crate::catalog::CollectionModel::Vector => "vector",
268        crate::catalog::CollectionModel::Hll => "hll",
269        crate::catalog::CollectionModel::Sketch => "sketch",
270        crate::catalog::CollectionModel::Filter => "filter",
271        crate::catalog::CollectionModel::Kv => "kv",
272        crate::catalog::CollectionModel::Config => "config",
273        crate::catalog::CollectionModel::Vault => "vault",
274        crate::catalog::CollectionModel::Mixed => "mixed",
275        crate::catalog::CollectionModel::TimeSeries => "timeseries",
276        crate::catalog::CollectionModel::Queue => "queue",
277        crate::catalog::CollectionModel::Metrics => "metrics",
278    }
279}
280
281pub fn normalize_row_fields_for_contract_at(
282    contract: &CollectionContract,
283    collection: &str,
284    fields: Vec<(String, Value)>,
285    mode: NormalizeMode,
286    now_ms: u64,
287) -> ContractResult<Vec<(String, Value)>> {
288    if contract.declared_model != crate::catalog::CollectionModel::Table
289        || (contract.declared_columns.is_empty()
290            && contract
291                .table_def
292                .as_ref()
293                .map(|table| table.columns.is_empty())
294                .unwrap_or(true))
295    {
296        return Ok(fields);
297    }
298
299    // Capture the pre-normalize value of created_at (if present) so
300    // Update mode can preserve it. Also capture updated_at to detect
301    // user attempts to set it via the patch payload.
302    //
303    // Heuristic for Update mode: if fields ALREADY contains a
304    // `created_at` whose value matches the row's on-disk entity, the
305    // caller is the patch pipeline carrying forward an auto-populated
306    // column — not a user mutation. Allow pass-through in that case,
307    // then restore the original value at the end.
308    let existing_created_at = if contract.timestamps_enabled && mode == NormalizeMode::Update {
309        fields
310            .iter()
311            .find(|(n, _)| n == "created_at")
312            .map(|(_, v)| v.clone())
313    } else {
314        None
315    };
316
317    // Reject user attempts to set runtime-managed timestamp columns.
318    // On Insert we reject any mention; on Update we only reject when
319    // the patch pipeline handed us a NEW value (not the one we
320    // auto-populated during the last insert).
321    if contract.timestamps_enabled && mode == NormalizeMode::Insert {
322        for (name, _) in &fields {
323            if name == "created_at" || name == "updated_at" {
324                return Err(ContractError::Query(format!(
325                    "collection '{}' manages '{}' automatically — do not set it in INSERT",
326                    collection, name
327                )));
328            }
329        }
330    }
331
332    let mut provided = std::collections::BTreeMap::new();
333    for (name, value) in &fields {
334        provided.insert(name.clone(), value.clone());
335    }
336
337    let resolved_columns = resolved_contract_columns(contract)?;
338    let declared_names: std::collections::BTreeSet<String> = resolved_columns
339        .iter()
340        .map(|column| column.name.clone())
341        .collect();
342    let unknown_fields: Vec<String> = fields
343        .iter()
344        .filter(|(name, _)| !declared_names.contains(name))
345        .map(|(name, _)| name.clone())
346        .collect();
347    if matches!(contract.schema_mode, crate::catalog::SchemaMode::Strict)
348        && !unknown_fields.is_empty()
349    {
350        return Err(ContractError::Query(format!(
351            "collection '{}' is strict and does not allow undeclared fields: {}",
352            collection,
353            unknown_fields.join(", ")
354        )));
355    }
356    let mut normalized = Vec::new();
357    let generated_names: std::collections::BTreeSet<&str> = contract
358        .declared_columns
359        .iter()
360        .filter(|column| column.generated.is_some())
361        .map(|column| column.name.as_str())
362        .collect();
363
364    for column in &resolved_columns {
365        if generated_names.contains(column.name.as_str()) {
366            // A derived value is never trusted, including one carried forward by UPDATE.
367            provided.remove(&column.name);
368            continue;
369        }
370        match provided.remove(&column.name) {
371            Some(value) => {
372                // Runtime-managed columns on Update: always overwrite
373                // with the runtime's own value (preserved created_at
374                // or fresh updated_at). User mutations are silently
375                // discarded because we reject them earlier.
376                if contract.timestamps_enabled && mode == NormalizeMode::Update {
377                    match column.name.as_str() {
378                        "created_at" => {
379                            normalized.push((
380                                column.name.clone(),
381                                existing_created_at
382                                    .clone()
383                                    .unwrap_or(Value::UnsignedInteger(now_ms)),
384                            ));
385                            continue;
386                        }
387                        "updated_at" => {
388                            normalized.push((column.name.clone(), Value::UnsignedInteger(now_ms)));
389                            continue;
390                        }
391                        _ => {}
392                    }
393                }
394                normalized.push((
395                    column.name.clone(),
396                    normalize_contract_value(collection, column, value)?,
397                ));
398            }
399            None => {
400                // Runtime-managed timestamp columns: auto-fill with now
401                // when the contract opted in. Both get the same value on
402                // first insert so callers can order by either.
403                if contract.timestamps_enabled
404                    && (column.name == "created_at" || column.name == "updated_at")
405                {
406                    normalized.push((column.name.clone(), Value::UnsignedInteger(now_ms)));
407                    continue;
408                }
409                if let Some(default) = &column.default {
410                    normalized.push((
411                        column.name.clone(),
412                        coerce_contract_literal(collection, &column.name, column, default)?,
413                    ));
414                } else if column.not_null {
415                    return Err(ContractError::Query(format!(
416                        "missing required column '{}' for collection '{}'",
417                        column.name, collection
418                    )));
419                }
420            }
421        }
422    }
423
424    for (name, value) in fields {
425        if !declared_names.contains(&name) {
426            normalized.push((name, value));
427        }
428    }
429
430    expressions::apply_contract_expressions(contract, &resolved_columns, &mut normalized)?;
431    Ok(normalized)
432}
433
434fn enforce_row_uniqueness_for_contract(
435    contract: &CollectionContract,
436    collection: &str,
437    fields: &[(String, Value)],
438    existing_rows: &[ContractRow],
439    exclude_id: Option<crate::storage::entity::EntityId>,
440) -> ContractResult<()> {
441    if let Some(conflict) = find_row_uniqueness_conflict_for_contract(
442        contract,
443        collection,
444        fields,
445        existing_rows,
446        None,
447        exclude_id,
448    )? {
449        return Err(row_uniqueness_error(&conflict.rule, collection));
450    }
451    Ok(())
452}
453
454pub fn find_row_uniqueness_conflict_for_contract(
455    contract: &CollectionContract,
456    collection: &str,
457    fields: &[(String, Value)],
458    existing_rows: &[ContractRow],
459    target: Option<&[String]>,
460    exclude_id: Option<crate::storage::entity::EntityId>,
461) -> ContractResult<Option<UniquenessConflict>> {
462    if !matches!(
463        contract.declared_model,
464        crate::catalog::CollectionModel::Table | crate::catalog::CollectionModel::Mixed
465    ) {
466        return Ok(None);
467    }
468
469    let mut rules = resolved_uniqueness_rules(contract);
470    if rules.is_empty() {
471        return Ok(None);
472    }
473    if let Some(target) = target {
474        rules.retain(|rule| uniqueness_columns_match(&rule.columns, target));
475        if rules.is_empty() {
476            return Err(ContractError::Query(format!(
477                "no unique or primary-key constraint on collection '{}' matches ON CONFLICT ({})",
478                collection,
479                target.join(", ")
480            )));
481        }
482    }
483
484    let input_fields: std::collections::BTreeMap<String, Value> = fields.iter().cloned().collect();
485
486    for rule in &rules {
487        let mut expected_signatures = Vec::new();
488        let mut skip_rule = false;
489
490        for column in &rule.columns {
491            match input_fields.get(column) {
492                Some(Value::Null) | None if rule.primary_key => {
493                    return Err(ContractError::Query(format!(
494                        "primary key '{}' in collection '{}' requires non-null column '{}'",
495                        rule.name, collection, column
496                    )))
497                }
498                Some(Value::Null) | None => {
499                    skip_rule = true;
500                    break;
501                }
502                Some(value) => {
503                    expected_signatures.push((column.clone(), value_signature(value)));
504                }
505            }
506        }
507
508        if skip_rule {
509            continue;
510        }
511
512        for existing in existing_rows {
513            if exclude_id.is_some_and(|id| id == existing.id) {
514                continue;
515            }
516
517            let duplicate = expected_signatures.iter().all(|(column, expected)| {
518                existing
519                    .fields
520                    .get(column)
521                    .map(|value| value_signature(value) == *expected)
522                    .unwrap_or(false)
523            });
524
525            if duplicate {
526                return Ok(Some(UniquenessConflict {
527                    rule: rule.clone(),
528                    entity_id: existing.id,
529                }));
530            }
531        }
532    }
533
534    Ok(None)
535}
536
537pub fn uniqueness_columns_match(rule_columns: &[String], target_columns: &[String]) -> bool {
538    rule_columns.len() == target_columns.len()
539        && rule_columns.iter().all(|rule_column| {
540            target_columns
541                .iter()
542                .any(|target_column| target_column.eq_ignore_ascii_case(rule_column))
543        })
544}
545
546pub fn row_uniqueness_error(rule: &UniquenessRule, collection: &str) -> ContractError {
547    let qualifier = if rule.primary_key {
548        "primary key"
549    } else {
550        "unique constraint"
551    };
552    ContractError::Query(format!(
553        "{} '{}' violated on collection '{}' for columns [{}]",
554        qualifier,
555        rule.name,
556        collection,
557        rule.columns.join(", ")
558    ))
559}
560
561pub fn enforce_row_batch_uniqueness_for_contract(
562    contract: &CollectionContract,
563    collection: &str,
564    rows: &[Vec<(String, Value)>],
565) -> ContractResult<()> {
566    if contract.declared_model != crate::catalog::CollectionModel::Table
567        && contract.declared_model != crate::catalog::CollectionModel::Mixed
568    {
569        return Ok(());
570    }
571
572    let rules = resolved_uniqueness_rules(contract);
573    if rules.is_empty() {
574        return Ok(());
575    }
576
577    for rule in &rules {
578        let mut seen = std::collections::HashMap::<String, usize>::new();
579        for (row_index, fields) in rows.iter().enumerate() {
580            let input_fields: std::collections::BTreeMap<String, Value> =
581                fields.iter().cloned().collect();
582            let mut signatures = Vec::new();
583            let mut skip_rule = false;
584
585            for column in &rule.columns {
586                match input_fields.get(column) {
587                    Some(Value::Null) | None if rule.primary_key => {
588                        return Err(ContractError::Query(format!(
589                            "primary key '{}' in collection '{}' requires non-null column '{}'",
590                            rule.name, collection, column
591                        )))
592                    }
593                    Some(Value::Null) | None => {
594                        skip_rule = true;
595                        break;
596                    }
597                    Some(value) => signatures.push(format!("{column}={}", value_signature(value))),
598                }
599            }
600
601            if skip_rule {
602                continue;
603            }
604
605            let signature = signatures.join("|");
606            if let Some(previous_index) = seen.insert(signature, row_index) {
607                return Err(ContractError::Query(format!(
608                    "batch insert violates uniqueness rule '{}' in collection '{}' between rows {} and {}",
609                    rule.name,
610                    collection,
611                    previous_index + 1,
612                    row_index + 1
613                )));
614            }
615        }
616    }
617
618    Ok(())
619}
620
621pub fn build_row_update_contract_plan_for_contract(
622    contract: &CollectionContract,
623) -> ContractResult<RowUpdateContractPlan> {
624    let declared_rules = if contract.declared_model == crate::catalog::CollectionModel::Table
625        && !(contract.declared_columns.is_empty()
626            && contract
627                .table_def
628                .as_ref()
629                .map(|table| table.columns.is_empty())
630                .unwrap_or(true))
631    {
632        resolved_contract_columns(contract)?
633            .into_iter()
634            .map(|rule| {
635                (
636                    rule.name.clone(),
637                    RowUpdateColumnRule {
638                        name: rule.name,
639                        data_type: rule.data_type,
640                        data_type_name: rule.data_type_name,
641                        not_null: rule.not_null,
642                        enum_variants: rule.enum_variants,
643                    },
644                )
645            })
646            .collect()
647    } else {
648        HashMap::new()
649    };
650
651    let unique_columns = if matches!(
652        contract.declared_model,
653        crate::catalog::CollectionModel::Table | crate::catalog::CollectionModel::Mixed
654    ) {
655        resolved_uniqueness_rules(contract)
656            .into_iter()
657            .flat_map(|rule| rule.columns.into_iter())
658            .map(|column| (column, ()))
659            .collect()
660    } else {
661        HashMap::new()
662    };
663
664    Ok(RowUpdateContractPlan {
665        has_expressions: has_contract_expressions(contract),
666        timestamps_enabled: contract.timestamps_enabled,
667        strict_schema: matches!(contract.schema_mode, crate::catalog::SchemaMode::Strict),
668        declared_rules,
669        unique_columns,
670    })
671}
672
673pub fn normalize_row_update_assignment_with_plan(
674    collection: &str,
675    column: &str,
676    value: Value,
677    row_contract_plan: Option<&RowUpdateContractPlan>,
678) -> ContractResult<Value> {
679    let Some(plan) = row_contract_plan else {
680        return Ok(value);
681    };
682
683    if plan.timestamps_enabled && (column == "created_at" || column == "updated_at") {
684        return Err(ContractError::Query(format!(
685            "collection '{}' manages '{}' automatically — do not set it in UPDATE",
686            collection, column
687        )));
688    }
689
690    if let Some(rule) = plan.declared_rules.get(column) {
691        let rule = ResolvedColumnRule {
692            name: rule.name.clone(),
693            data_type: rule.data_type,
694            data_type_name: rule.data_type_name.clone(),
695            not_null: rule.not_null,
696            default: None,
697            enum_variants: rule.enum_variants.clone(),
698        };
699        normalize_contract_value(collection, &rule, value)
700    } else if plan.strict_schema {
701        Err(ContractError::Query(format!(
702            "collection '{}' is strict and does not allow undeclared fields: {}",
703            collection, column
704        )))
705    } else {
706        Ok(value)
707    }
708}
709
710pub fn normalize_row_update_value_for_rule(
711    collection: &str,
712    value: Value,
713    row_rule: Option<&RowUpdateColumnRule>,
714) -> ContractResult<Value> {
715    let Some(rule) = row_rule else {
716        return Ok(value);
717    };
718
719    let rule = ResolvedColumnRule {
720        name: rule.name.clone(),
721        data_type: rule.data_type,
722        data_type_name: rule.data_type_name.clone(),
723        not_null: rule.not_null,
724        default: None,
725        enum_variants: rule.enum_variants.clone(),
726    };
727    normalize_contract_value(collection, &rule, value)
728}
729
730pub fn resolved_uniqueness_rules(contract: &CollectionContract) -> Vec<UniquenessRule> {
731    let mut rules = Vec::new();
732
733    if let Some(table_def) = &contract.table_def {
734        if !table_def.primary_key.is_empty() {
735            rules.push(UniquenessRule {
736                name: "primary_key".to_string(),
737                columns: table_def.primary_key.clone(),
738                primary_key: true,
739            });
740        }
741
742        for constraint in &table_def.constraints {
743            if matches!(
744                constraint.constraint_type,
745                reddb_types::ConstraintType::PrimaryKey
746            ) && !constraint.columns.is_empty()
747            {
748                rules.push(UniquenessRule {
749                    name: constraint.name.clone(),
750                    columns: constraint.columns.clone(),
751                    primary_key: true,
752                });
753            } else if matches!(
754                constraint.constraint_type,
755                reddb_types::ConstraintType::Unique
756            ) && !constraint.columns.is_empty()
757            {
758                rules.push(UniquenessRule {
759                    name: constraint.name.clone(),
760                    columns: constraint.columns.clone(),
761                    primary_key: false,
762                });
763            }
764        }
765    } else {
766        for column in &contract.declared_columns {
767            if column.primary_key {
768                rules.push(UniquenessRule {
769                    name: format!("pk_{}", column.name),
770                    columns: vec![column.name.clone()],
771                    primary_key: true,
772                });
773            } else if column.unique {
774                rules.push(UniquenessRule {
775                    name: format!("uniq_{}", column.name),
776                    columns: vec![column.name.clone()],
777                    primary_key: false,
778                });
779            }
780        }
781    }
782
783    let mut dedup = std::collections::BTreeSet::new();
784    rules
785        .into_iter()
786        .filter(|rule| dedup.insert((rule.primary_key, rule.columns.clone())))
787        .collect()
788}
789
790pub fn value_signature(value: &Value) -> String {
791    format!("{value:?}")
792}
793
794pub fn normalize_declared_value(
795    context: &str,
796    name: &str,
797    sql_type: &reddb_types::SqlTypeName,
798    value: Value,
799) -> ContractResult<Value> {
800    let data_type = DataType::from_sql_type_name(sql_type)
801        .ok_or_else(|| ContractError::Query(format!("unknown declared type '{sql_type}'")))?;
802    let rule = ResolvedColumnRule {
803        name: name.to_string(),
804        data_type,
805        data_type_name: sql_type.to_string(),
806        not_null: false,
807        default: None,
808        enum_variants: sql_type.enum_variants().unwrap_or_default(),
809    };
810    normalize_contract_value(context, &rule, value)
811}
812
813fn normalize_contract_value(
814    collection: &str,
815    column: &ResolvedColumnRule,
816    value: Value,
817) -> ContractResult<Value> {
818    if matches!(value, Value::Null) {
819        if column.not_null {
820            return Err(ContractError::Query(format!(
821                "column '{}' in collection '{}' cannot be null",
822                column.name, collection
823            )));
824        }
825        return Ok(Value::Null);
826    }
827
828    let target = column.data_type;
829    if value_matches_declared_type(&value, target) {
830        return Ok(value);
831    }
832
833    let Some(raw) = value_to_coercion_input(&value) else {
834        return Err(ContractError::Query(format!(
835            "column '{}' in collection '{}' requires type '{}' but value cannot be coerced",
836            column.name, collection, column.data_type_name
837        )));
838    };
839
840    coerce_contract_literal(collection, &column.name, column, &raw)
841}
842
843fn coerce_contract_literal(
844    collection: &str,
845    column_name: &str,
846    column: &ResolvedColumnRule,
847    raw: &str,
848) -> ContractResult<Value> {
849    let target = column.data_type;
850    match target {
851        DataType::Blob => Ok(Value::Blob(raw.as_bytes().to_vec())),
852        // ADR 0067 (#1721): a JSON value is written as an inline strict-JSON
853        // literal — a bare string is not silently wrapped as JSON. Wrap a
854        // runtime string with `JSON_PARSE(<expr>)`. (An inline literal already
855        // arrives as `Value::Json` and never reaches this coercion.)
856        DataType::Json => Err(ContractError::Query(format!(
857            "column '{column_name}' in collection '{collection}' requires an inline strict-JSON \
858             literal (e.g. `{{\"k\": \"v\"}}`) or `JSON_PARSE(<expr>)`; a bare string is not \
859             coerced to JSON (ADR 0067)"
860        ))),
861        DataType::Timestamp => raw.parse::<i64>().map(Value::Timestamp).map_err(|err| {
862            ContractError::Query(format!(
863                "failed to coerce column '{}' in collection '{}' to '{}': {}",
864                column_name, collection, column.data_type_name, err
865            ))
866        }),
867        DataType::Duration => raw.parse::<i64>().map(Value::Duration).map_err(|err| {
868            ContractError::Query(format!(
869                "failed to coerce column '{}' in collection '{}' to '{}': {}",
870                column_name, collection, column.data_type_name, err
871            ))
872        }),
873        DataType::Vector | DataType::Array => Err(ContractError::Query(format!(
874            "column '{}' in collection '{}' requires '{}' and only typed values are accepted for this type",
875            column_name, collection, column.data_type_name
876        ))),
877        _ => coerce_schema_value(raw, target, Some(column.enum_variants.as_slice())).map_err(
878            |err| {
879                ContractError::Query(format!(
880                    "failed to coerce column '{}' in collection '{}' to '{}': {}",
881                    column_name, collection, column.data_type_name, err
882                ))
883            },
884        ),
885    }
886}
887
888fn value_matches_declared_type(value: &Value, target: DataType) -> bool {
889    matches!(
890        (value, target),
891        (Value::Null, _)
892            | (Value::Integer(_), DataType::Integer)
893            | (Value::UnsignedInteger(_), DataType::UnsignedInteger)
894            | (Value::Float(_), DataType::Float)
895            | (Value::Text(_), DataType::Text)
896            | (Value::Blob(_), DataType::Blob)
897            | (Value::Boolean(_), DataType::Boolean)
898            | (Value::Timestamp(_), DataType::Timestamp)
899            | (Value::Duration(_), DataType::Duration)
900            | (Value::IpAddr(_), DataType::IpAddr)
901            | (Value::MacAddr(_), DataType::MacAddr)
902            | (Value::Vector(_), DataType::Vector)
903            | (Value::Json(_), DataType::Json)
904            | (Value::Uuid(_), DataType::Uuid)
905            | (Value::NodeRef(_), DataType::NodeRef)
906            | (Value::EdgeRef(_), DataType::EdgeRef)
907            | (Value::VectorRef(_, _), DataType::VectorRef)
908            | (Value::RowRef(_, _), DataType::RowRef)
909            | (Value::Color(_), DataType::Color)
910            | (Value::Email(_), DataType::Email)
911            | (Value::Url(_), DataType::Url)
912            | (Value::Phone(_), DataType::Phone)
913            | (Value::Semver(_), DataType::Semver)
914            | (Value::Cidr(_, _), DataType::Cidr)
915            | (Value::Date(_), DataType::Date)
916            | (Value::Time(_), DataType::Time)
917            | (Value::Decimal(_), DataType::Decimal)
918            | (Value::DecimalText(_), DataType::DecimalText)
919            | (Value::EnumValue(_), DataType::Enum)
920            | (Value::Array(_), DataType::Array)
921            | (Value::TimestampMs(_), DataType::TimestampMs)
922            | (Value::Ipv4(_), DataType::Ipv4)
923            | (Value::Ipv6(_), DataType::Ipv6)
924            | (Value::Subnet(_, _), DataType::Subnet)
925            | (Value::Port(_), DataType::Port)
926            | (Value::Latitude(_), DataType::Latitude)
927            | (Value::Longitude(_), DataType::Longitude)
928            | (Value::GeoPoint(_, _), DataType::GeoPoint)
929            | (Value::Country2(_), DataType::Country2)
930            | (Value::Country3(_), DataType::Country3)
931            | (Value::Lang2(_), DataType::Lang2)
932            | (Value::Lang5(_), DataType::Lang5)
933            | (Value::Currency(_), DataType::Currency)
934            | (Value::ColorAlpha(_), DataType::ColorAlpha)
935            | (Value::BigInt(_), DataType::BigInt)
936            | (Value::KeyRef(_, _), DataType::KeyRef)
937            | (Value::DocRef(_, _), DataType::DocRef)
938            | (Value::TableRef(_), DataType::TableRef)
939            | (Value::PageRef(_), DataType::PageRef)
940            | (Value::Secret(_), DataType::Secret)
941            | (Value::Password(_), DataType::Password)
942    )
943}
944
945fn value_to_coercion_input(value: &Value) -> Option<String> {
946    match value {
947        Value::Null => None,
948        Value::Integer(value) => Some(value.to_string()),
949        Value::UnsignedInteger(value) => Some(value.to_string()),
950        Value::Float(value) => Some(value.to_string()),
951        Value::Text(value) => Some(value.to_string()),
952        Value::Blob(value) => String::from_utf8(value.clone()).ok(),
953        Value::Boolean(value) => Some(value.to_string()),
954        Value::Timestamp(value) => Some(value.to_string()),
955        Value::Duration(value) => Some(value.to_string()),
956        Value::IpAddr(value) => Some(value.to_string()),
957        Value::MacAddr(value) => Some(format!(
958            "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
959            value[0], value[1], value[2], value[3], value[4], value[5]
960        )),
961        Value::Json(value) => Some(String::from_utf8_lossy(value).to_string()),
962        Value::Email(value) => Some(value.clone()),
963        Value::Url(value) => Some(value.clone()),
964        Value::Phone(value) => Some(value.to_string()),
965        Value::Semver(value) => Some(format!(
966            "{}.{}.{}",
967            value / 1_000_000,
968            (value / 1_000) % 1_000,
969            value % 1_000
970        )),
971        Value::Date(value) => Some(value.to_string()),
972        Value::Time(value) => Some(value.to_string()),
973        Value::Decimal(value) => Some(value.to_string()),
974        Value::DecimalText(value) => Some(value.clone()),
975        Value::TimestampMs(value) => Some(value.to_string()),
976        Value::Ipv4(value) => Some(format!(
977            "{}.{}.{}.{}",
978            (value >> 24) & 0xFF,
979            (value >> 16) & 0xFF,
980            (value >> 8) & 0xFF,
981            value & 0xFF
982        )),
983        Value::Port(value) => Some(value.to_string()),
984        Value::Latitude(value) => Some((*value as f64 / 1_000_000.0).to_string()),
985        Value::Longitude(value) => Some((*value as f64 / 1_000_000.0).to_string()),
986        Value::GeoPoint(lat, lon) => Some(format!(
987            "{},{}",
988            *lat as f64 / 1_000_000.0,
989            *lon as f64 / 1_000_000.0
990        )),
991        Value::BigInt(value) => Some(value.to_string()),
992        Value::TableRef(value) => Some(value.clone()),
993        Value::PageRef(value) => Some(value.to_string()),
994        Value::Password(value) => Some(value.clone()),
995        _ => None,
996    }
997}