1use 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
67pub 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 Insert,
239 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 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 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 provided.remove(&column.name);
368 continue;
369 }
370 match provided.remove(&column.name) {
371 Some(value) => {
372 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 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 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}