1use std::collections::{HashMap, HashSet};
8
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
21#[serde(default)]
22pub struct SchemaConfig {
23 #[serde(skip_serializing_if = "Option::is_none")]
26 pub owner: Option<String>,
27
28 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub version: Option<u32>,
33
34 #[serde(default = "default_auto_migrate")]
40 pub auto_migrate: bool,
41
42 pub conversations: TableConfig,
43 pub responses: TableConfig,
44 pub conversation_items: TableConfig,
45 pub conversation_item_links: TableConfig,
46 pub conversation_memories: TableConfig,
47}
48
49#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
51#[serde(default)]
52pub struct TableConfig {
53 pub table: String,
55
56 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
59 pub columns: HashMap<String, String>,
60
61 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
65 pub extra_columns: HashMap<String, ColumnDef>,
66
67 #[serde(default, skip_serializing_if = "HashSet::is_empty")]
71 pub skip_columns: HashSet<String>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
76pub struct ColumnDef {
77 pub sql_type: String,
79
80 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub default_value: Option<Value>,
84}
85
86fn default_auto_migrate() -> bool {
91 std::env::var("DB_AUTO_MIGRATE")
92 .ok()
93 .map(|v| v.eq_ignore_ascii_case("true") || v == "1")
94 .unwrap_or(false)
95}
96
97impl Default for SchemaConfig {
98 fn default() -> Self {
99 Self {
100 owner: None,
101 version: None,
102 auto_migrate: default_auto_migrate(),
103 conversations: TableConfig::with_table("conversations"),
104 responses: TableConfig::with_table("responses"),
105 conversation_items: TableConfig::with_table("conversation_items"),
106 conversation_item_links: TableConfig::with_table("conversation_item_links"),
107 conversation_memories: TableConfig::with_table("conversation_memories"),
108 }
109 }
110}
111
112impl TableConfig {
117 pub fn with_table(name: &str) -> Self {
119 Self {
120 table: name.to_string(),
121 ..Default::default()
122 }
123 }
124
125 pub fn col<'a>(&'a self, field: &'a str) -> &'a str {
130 self.columns.get(field).map(String::as_str).unwrap_or(field)
131 }
132
133 pub fn is_skipped(&self, field: &str) -> bool {
137 self.skip_columns.contains(field)
138 }
139
140 pub fn qualified_table(&self, owner: Option<&str>) -> String {
147 match owner {
148 Some(o) => format!("{o}.\"{}\"", self.table),
149 None => self.table.clone(),
150 }
151 }
152}
153
154impl SchemaConfig {
159 pub fn uppercase_for_oracle(&mut self) {
167 for tc in [
168 &mut self.conversations,
169 &mut self.responses,
170 &mut self.conversation_items,
171 &mut self.conversation_item_links,
172 &mut self.conversation_memories,
173 ] {
174 tc.table.make_ascii_uppercase();
175 for val in tc.columns.values_mut() {
176 val.make_ascii_uppercase();
177 }
178 let keys: Vec<String> = tc.extra_columns.keys().cloned().collect();
181 for key in keys {
182 if let Some(def) = tc.extra_columns.remove(&key) {
183 tc.extra_columns.insert(key.to_ascii_uppercase(), def);
184 }
185 }
186 }
191 }
192
193 pub fn validate(&self) -> Result<(), String> {
195 if let Some(ref owner) = self.owner {
197 validate_identifier(owner).map_err(|e| format!("owner: {e}"))?;
198 }
199
200 Self::validate_table("conversations", &self.conversations)?;
202 Self::validate_table("responses", &self.responses)?;
203 Self::validate_table("conversation_items", &self.conversation_items)?;
204 Self::validate_table("conversation_item_links", &self.conversation_item_links)?;
205 Self::validate_table("conversation_memories", &self.conversation_memories)?;
206
207 Ok(())
208 }
209
210 fn validate_table(label: &str, tc: &TableConfig) -> Result<(), String> {
211 validate_identifier(&tc.table).map_err(|e| {
212 if tc.table.is_empty() {
213 format!("{label}.table: table name is required (got empty string — did you omit the 'table' key in your config?)")
214 } else {
215 format!("{label}.table: {e}")
216 }
217 })?;
218
219 for (logical, physical) in &tc.columns {
220 validate_identifier(logical)
221 .map_err(|e| format!("{label}.columns key '{logical}': {e}"))?;
222 validate_identifier(physical)
223 .map_err(|e| format!("{label}.columns value '{physical}': {e}"))?;
224 }
225
226 for (name, def) in &tc.extra_columns {
227 validate_identifier(name)
228 .map_err(|e| format!("{label}.extra_columns key '{name}': {e}"))?;
229 if def.sql_type.is_empty() {
230 return Err(format!(
231 "{label}.extra_columns['{name}']: sql_type must not be empty"
232 ));
233 }
234 validate_sql_type(&def.sql_type)
235 .map_err(|e| format!("{label}.extra_columns['{name}'].sql_type: {e}"))?;
236 }
237
238 let core = core_columns_for(label);
241 for name in tc.extra_columns.keys() {
242 let upper = name.to_ascii_uppercase();
243 if core.iter().any(|c| c.to_ascii_uppercase() == upper) {
244 return Err(format!(
245 "{label}.extra_columns: '{name}' shadows a core column name"
246 ));
247 }
248 }
249
250 let mut folded: HashSet<String> = HashSet::new();
253 for name in tc.extra_columns.keys() {
254 let upper = name.to_ascii_uppercase();
255 if !folded.insert(upper) {
256 return Err(format!(
257 "{label}.extra_columns: case-insensitive collision on '{name}' \
258 (Oracle normalizes identifiers to uppercase)"
259 ));
260 }
261 }
262
263 for name in &tc.skip_columns {
264 validate_identifier(name).map_err(|e| format!("{label}.skip_columns '{name}': {e}"))?;
265 if primary_key_columns_for(label).contains(&name.as_str()) {
266 return Err(format!(
267 "{label}.skip_columns: cannot skip '{name}' — it is part of the primary key"
268 ));
269 }
270 if !core.contains(&name.as_str()) {
271 return Err(format!(
272 "{label}.skip_columns: '{name}' is not a recognized column \
273 (known: {core:?})"
274 ));
275 }
276 }
277
278 Ok(())
279 }
280}
281
282fn primary_key_columns_for(label: &str) -> &'static [&'static str] {
283 match label {
284 "conversation_memories" => &["memory_id"],
285 _ => &["id"],
286 }
287}
288
289fn core_columns_for(label: &str) -> &'static [&'static str] {
292 match label {
293 "conversations" => &["id", "created_at", "metadata"],
294 "responses" => &[
295 "id",
296 "conversation_id",
297 "previous_response_id",
298 "input",
299 "created_at",
300 "safety_identifier",
301 "model",
302 "raw_response",
303 ],
304 "conversation_items" => &[
305 "id",
306 "response_id",
307 "item_type",
308 "role",
309 "content",
310 "status",
311 "created_at",
312 ],
313 "conversation_item_links" => &["conversation_id", "item_id", "added_at"],
314 "conversation_memories" => &[
315 "memory_id",
316 "conversation_id",
317 "conversation_version",
318 "response_id",
319 "memory_type",
320 "status",
321 "attempt",
322 "owner_id",
323 "next_run_at",
324 "lease_until",
325 "content",
326 "memory_config",
327 "scope_id",
328 "error_msg",
329 "created_at",
330 "updated_at",
331 ],
332 _ => &[],
333 }
334}
335
336const MAX_IDENTIFIER_LEN: usize = 128;
339
340fn validate_identifier(name: &str) -> Result<(), String> {
342 if name.is_empty() {
343 return Err("identifier must not be empty".to_string());
344 }
345 if name.len() > MAX_IDENTIFIER_LEN {
346 return Err(format!(
347 "identifier '{name}' exceeds maximum length of {MAX_IDENTIFIER_LEN} characters"
348 ));
349 }
350 if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
351 return Err(format!(
352 "invalid identifier '{name}' — only ASCII alphanumeric and underscores allowed"
353 ));
354 }
355 Ok(())
356}
357
358const MAX_SQL_TYPE_LEN: usize = 64;
360
361fn validate_sql_type(sql_type: &str) -> Result<(), String> {
367 if sql_type.trim().is_empty() {
368 return Err("sql_type must not be whitespace-only".to_string());
369 }
370 if sql_type.len() > MAX_SQL_TYPE_LEN {
371 return Err(format!(
372 "sql_type '{sql_type}' exceeds maximum length of {MAX_SQL_TYPE_LEN} characters"
373 ));
374 }
375 if !sql_type
376 .chars()
377 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | ' ' | '(' | ')' | ',' | '.'))
378 {
379 return Err(format!(
380 "invalid sql_type '{sql_type}' — only ASCII alphanumeric, underscore, space, \
381 parentheses, comma, and period are allowed"
382 ));
383 }
384 Ok(())
385}
386
387#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
398 fn default_config_matches_hardcoded_names() {
399 let cfg = SchemaConfig::default();
400 assert_eq!(cfg.conversations.table, "conversations");
401 assert_eq!(cfg.responses.table, "responses");
402 assert_eq!(cfg.conversation_items.table, "conversation_items");
403 assert_eq!(cfg.conversation_item_links.table, "conversation_item_links");
404 assert_eq!(cfg.conversation_memories.table, "conversation_memories");
405 assert!(cfg.owner.is_none());
406 }
407
408 #[test]
409 fn default_config_validates_successfully() {
410 SchemaConfig::default()
411 .validate()
412 .expect("default config should be valid");
413 }
414
415 #[test]
418 fn col_returns_field_name_when_no_override() {
419 let tc = TableConfig::with_table("t");
420 assert_eq!(tc.col("id"), "id");
421 assert_eq!(tc.col("created_at"), "created_at");
422 }
423
424 #[test]
425 fn col_returns_override_when_configured() {
426 let mut tc = TableConfig::with_table("t");
427 tc.columns
428 .insert("id".to_string(), "CONVERSATION_ID".to_string());
429 assert_eq!(tc.col("id"), "CONVERSATION_ID");
430 assert_eq!(tc.col("created_at"), "created_at");
432 }
433
434 #[test]
437 fn qualified_table_without_owner() {
438 let tc = TableConfig::with_table("conversations");
439 assert_eq!(tc.qualified_table(None), "conversations");
440 }
441
442 #[test]
443 fn qualified_table_with_owner() {
444 let tc = TableConfig::with_table("CONVERSATIONS");
445 assert_eq!(tc.qualified_table(Some("ADMIN")), "ADMIN.\"CONVERSATIONS\"");
446 }
447
448 #[test]
451 fn uppercase_for_oracle_converts_defaults() {
452 let mut cfg = SchemaConfig::default();
453 cfg.uppercase_for_oracle();
454 assert_eq!(cfg.conversations.table, "CONVERSATIONS");
455 assert_eq!(cfg.responses.table, "RESPONSES");
456 assert_eq!(cfg.conversation_items.table, "CONVERSATION_ITEMS");
457 assert_eq!(cfg.conversation_item_links.table, "CONVERSATION_ITEM_LINKS");
458 assert_eq!(cfg.conversation_memories.table, "CONVERSATION_MEMORIES");
459 cfg.validate().expect("uppercased config should be valid");
460 }
461
462 #[test]
463 fn uppercase_for_oracle_converts_custom_table_and_columns() {
464 let mut cfg = SchemaConfig::default();
465 cfg.conversations.table = "my_convos".to_string();
466 cfg.conversations
467 .columns
468 .insert("id".to_string(), "conv_id".to_string());
469 cfg.uppercase_for_oracle();
470 assert_eq!(cfg.conversations.table, "MY_CONVOS");
471 assert_eq!(cfg.conversations.col("id"), "CONV_ID");
472 }
473
474 #[test]
477 fn validate_accepts_valid_identifiers() {
478 let mut cfg = SchemaConfig {
479 owner: Some("ADMIN_01".to_string()),
480 ..Default::default()
481 };
482 cfg.conversations.table = "MY_CONVERSATIONS".to_string();
483 cfg.conversations
484 .columns
485 .insert("id".to_string(), "CONV_ID".to_string());
486 cfg.validate().expect("should be valid");
487 }
488
489 #[test]
490 fn validate_rejects_empty_table_name_with_helpful_message() {
491 let mut cfg = SchemaConfig::default();
492 cfg.conversations.table = String::new();
493 let err = cfg.validate().unwrap_err();
494 assert!(
495 err.contains("conversations.table") && err.contains("table name is required"),
496 "unexpected: {err}"
497 );
498 }
499
500 #[test]
501 fn validate_rejects_overly_long_identifier() {
502 let mut cfg = SchemaConfig::default();
503 cfg.conversations.table = "a".repeat(129);
504 let err = cfg.validate().unwrap_err();
505 assert!(err.contains("exceeds maximum length"), "unexpected: {err}");
506 }
507
508 #[test]
509 fn validate_rejects_special_characters() {
510 let mut cfg = SchemaConfig::default();
511 cfg.conversations.table = "table;DROP".to_string();
512 let err = cfg.validate().unwrap_err();
513 assert!(
514 err.contains("conversations.table") && err.contains("invalid identifier"),
515 "unexpected: {err}"
516 );
517 }
518
519 #[test]
520 fn validate_rejects_dots_in_owner() {
521 let cfg = SchemaConfig {
522 owner: Some("ADMIN.SCHEMA".to_string()),
523 ..Default::default()
524 };
525 let err = cfg.validate().unwrap_err();
526 assert!(
527 err.contains("owner") && err.contains("invalid identifier"),
528 "unexpected: {err}"
529 );
530 }
531
532 #[test]
533 fn validate_rejects_invalid_column_override_key() {
534 let mut cfg = SchemaConfig::default();
535 cfg.responses
536 .columns
537 .insert("bad;col".to_string(), "GOOD_COL".to_string());
538 let err = cfg.validate().unwrap_err();
539 assert!(
540 err.contains("columns key") && err.contains("invalid identifier"),
541 "unexpected: {err}"
542 );
543 }
544
545 #[test]
546 fn validate_rejects_invalid_column_override_value() {
547 let mut cfg = SchemaConfig::default();
548 cfg.responses
549 .columns
550 .insert("id".to_string(), "bad column!".to_string());
551 let err = cfg.validate().unwrap_err();
552 assert!(
553 err.contains("columns value") && err.contains("invalid identifier"),
554 "unexpected: {err}"
555 );
556 }
557
558 #[test]
561 fn serde_roundtrip_default() {
562 let cfg = SchemaConfig::default();
563 let json = serde_json::to_string(&cfg).expect("serialize");
564 let restored: SchemaConfig = serde_json::from_str(&json).expect("deserialize");
565 assert_eq!(cfg, restored);
566 }
567
568 #[test]
569 fn serde_roundtrip_custom() {
570 let mut cfg = SchemaConfig {
571 owner: Some("ADMIN".to_string()),
572 ..Default::default()
573 };
574 cfg.conversations.table = "CONVERSATIONS".to_string();
575 cfg.conversations
576 .columns
577 .insert("id".to_string(), "CONVERSATION_ID".to_string());
578
579 let json = serde_json::to_string(&cfg).expect("serialize");
580 let restored: SchemaConfig = serde_json::from_str(&json).expect("deserialize");
581 assert_eq!(cfg, restored);
582 }
583
584 #[test]
585 fn serde_deserialize_empty_object_uses_defaults() {
586 let cfg: SchemaConfig = serde_json::from_str("{}").expect("deserialize empty");
587 assert_eq!(cfg, SchemaConfig::default());
588 }
589
590 #[test]
593 fn serde_roundtrip_with_version_and_auto_migrate() {
594 let cfg = SchemaConfig {
595 version: Some(3),
596 auto_migrate: false,
597 ..Default::default()
598 };
599 let json = serde_json::to_string(&cfg).expect("serialize");
600 let restored: SchemaConfig = serde_json::from_str(&json).expect("deserialize");
601 assert_eq!(restored.version, Some(3));
602 assert!(!restored.auto_migrate);
603 }
604
605 #[test]
606 fn serde_defaults_version_none_and_auto_migrate_false() {
607 let cfg: SchemaConfig = serde_json::from_str("{}").expect("deserialize empty");
608 assert_eq!(cfg.version, None);
609 assert!(!cfg.auto_migrate);
610 }
611
612 #[test]
613 fn serde_version_none_is_omitted_from_json() {
614 let cfg = SchemaConfig::default();
615 let json = serde_json::to_string(&cfg).expect("serialize");
616 assert!(
617 !json.contains("version"),
618 "version:None should be skipped: {json}"
619 );
620 }
621
622 #[test]
625 fn is_skipped_returns_false_by_default() {
626 let tc = TableConfig::with_table("t");
627 assert!(!tc.is_skipped("id"));
628 assert!(!tc.is_skipped("safety_identifier"));
629 }
630
631 #[test]
632 fn is_skipped_returns_true_for_configured_fields() {
633 let mut tc = TableConfig::with_table("t");
634 tc.skip_columns.insert("safety_identifier".to_string());
635 tc.skip_columns.insert("raw_response".to_string());
636 assert!(tc.is_skipped("safety_identifier"));
637 assert!(tc.is_skipped("raw_response"));
638 assert!(!tc.is_skipped("id"));
639 }
640
641 #[test]
644 fn extra_columns_default_is_empty() {
645 let tc = TableConfig::with_table("t");
646 assert!(tc.extra_columns.is_empty());
647 }
648
649 #[test]
650 fn validate_accepts_valid_extra_columns() {
651 let mut cfg = SchemaConfig::default();
652 cfg.conversations.extra_columns.insert(
653 "EXPIRES_AT".to_string(),
654 ColumnDef {
655 sql_type: "TIMESTAMP".to_string(),
656 default_value: None,
657 },
658 );
659 cfg.validate().expect("should be valid");
660 }
661
662 #[test]
663 fn validate_rejects_invalid_extra_column_name() {
664 let mut cfg = SchemaConfig::default();
665 cfg.conversations.extra_columns.insert(
666 "bad;col".to_string(),
667 ColumnDef {
668 sql_type: "TEXT".to_string(),
669 default_value: None,
670 },
671 );
672 let err = cfg.validate().unwrap_err();
673 assert!(
674 err.contains("extra_columns key") && err.contains("invalid identifier"),
675 "unexpected: {err}"
676 );
677 }
678
679 #[test]
680 fn validate_rejects_empty_sql_type() {
681 let mut cfg = SchemaConfig::default();
682 cfg.conversations.extra_columns.insert(
683 "MY_COL".to_string(),
684 ColumnDef {
685 sql_type: String::new(),
686 default_value: None,
687 },
688 );
689 let err = cfg.validate().unwrap_err();
690 assert!(
691 err.contains("sql_type must not be empty"),
692 "unexpected: {err}"
693 );
694 }
695
696 #[test]
697 fn validate_rejects_invalid_skip_column_name() {
698 let mut cfg = SchemaConfig::default();
699 cfg.responses.skip_columns.insert("bad;col".to_string());
700 let err = cfg.validate().unwrap_err();
701 assert!(
702 err.contains("skip_columns") && err.contains("invalid identifier"),
703 "unexpected: {err}"
704 );
705 }
706
707 #[test]
708 fn validate_accepts_valid_skip_columns() {
709 let mut cfg = SchemaConfig::default();
710 cfg.responses
711 .skip_columns
712 .insert("safety_identifier".to_string());
713 cfg.validate().expect("should be valid");
714 }
715
716 #[test]
717 fn validate_rejects_skip_id() {
718 let mut cfg = SchemaConfig::default();
719 cfg.responses.skip_columns.insert("id".to_string());
720 let err = cfg.validate().unwrap_err();
721 assert!(
722 err.contains("cannot skip 'id'") && err.contains("primary key"),
723 "unexpected: {err}"
724 );
725 }
726
727 #[test]
728 fn validate_rejects_skip_conversation_memory_id() {
729 let mut cfg = SchemaConfig::default();
730 cfg.conversation_memories
731 .skip_columns
732 .insert("memory_id".to_string());
733 let err = cfg.validate().unwrap_err();
734 assert!(
735 err.contains("cannot skip 'memory_id'") && err.contains("primary key"),
736 "unexpected: {err}"
737 );
738 }
739
740 #[test]
743 fn validate_accepts_valid_sql_types() {
744 let mut cfg = SchemaConfig::default();
745 for sql_type in [
746 "VARCHAR(128)",
747 "TEXT",
748 "TIMESTAMP",
749 "TIMESTAMP WITH TIME ZONE",
750 "NUMBER(10,2)",
751 "VARCHAR2(256)",
752 "BIGINT",
753 ] {
754 cfg.conversations.extra_columns.insert(
755 "TEST_COL".to_string(),
756 ColumnDef {
757 sql_type: sql_type.to_string(),
758 default_value: None,
759 },
760 );
761 cfg.validate()
762 .unwrap_or_else(|e| panic!("sql_type '{sql_type}' should be valid: {e}"));
763 }
764 }
765
766 #[test]
767 fn validate_rejects_sql_injection_in_sql_type() {
768 let mut cfg = SchemaConfig::default();
769 cfg.conversations.extra_columns.insert(
770 "MY_COL".to_string(),
771 ColumnDef {
772 sql_type: "TEXT); DROP TABLE responses; --".to_string(),
773 default_value: None,
774 },
775 );
776 let err = cfg.validate().unwrap_err();
777 assert!(
778 err.contains("sql_type") && err.contains("invalid"),
779 "unexpected: {err}"
780 );
781 }
782
783 #[test]
784 fn validate_rejects_overly_long_sql_type() {
785 let mut cfg = SchemaConfig::default();
786 cfg.conversations.extra_columns.insert(
787 "MY_COL".to_string(),
788 ColumnDef {
789 sql_type: "A".repeat(65),
790 default_value: None,
791 },
792 );
793 let err = cfg.validate().unwrap_err();
794 assert!(
795 err.contains("sql_type") && err.contains("exceeds maximum length"),
796 "unexpected: {err}"
797 );
798 }
799
800 #[test]
803 fn uppercase_for_oracle_converts_extra_column_keys() {
804 let mut cfg = SchemaConfig::default();
805 cfg.conversations.extra_columns.insert(
806 "expires_at".to_string(),
807 ColumnDef {
808 sql_type: "TIMESTAMP".to_string(),
809 default_value: None,
810 },
811 );
812 cfg.uppercase_for_oracle();
813 assert!(cfg.conversations.extra_columns.contains_key("EXPIRES_AT"));
814 assert!(!cfg.conversations.extra_columns.contains_key("expires_at"));
815 }
816
817 #[test]
818 fn uppercase_for_oracle_preserves_skip_column_names_lowercase() {
819 let mut cfg = SchemaConfig::default();
820 cfg.responses
821 .skip_columns
822 .insert("safety_identifier".to_string());
823 cfg.uppercase_for_oracle();
824 assert!(cfg.responses.skip_columns.contains("safety_identifier"));
826 assert!(!cfg.responses.skip_columns.contains("SAFETY_IDENTIFIER"));
827 }
828
829 #[test]
832 fn serde_roundtrip_with_extra_and_skip() {
833 let mut cfg = SchemaConfig::default();
834 cfg.conversations.extra_columns.insert(
835 "EXPIRES_AT".to_string(),
836 ColumnDef {
837 sql_type: "TIMESTAMP".to_string(),
838 default_value: Some(Value::String("2099-01-01".to_string())),
839 },
840 );
841 cfg.responses
842 .skip_columns
843 .insert("safety_identifier".to_string());
844
845 let json = serde_json::to_string(&cfg).expect("serialize");
846 let restored: SchemaConfig = serde_json::from_str(&json).expect("deserialize");
847 assert_eq!(cfg, restored);
848 }
849
850 #[test]
851 fn validate_rejects_extra_column_shadowing_core_column() {
852 let mut cfg = SchemaConfig::default();
853 cfg.responses.extra_columns.insert(
854 "CREATED_AT".to_string(),
855 ColumnDef {
856 sql_type: "TIMESTAMP".to_string(),
857 default_value: None,
858 },
859 );
860 let err = cfg.validate().unwrap_err();
861 assert!(err.contains("shadows a core column"), "unexpected: {err}");
862
863 let mut cfg2 = SchemaConfig::default();
865 cfg2.conversations.extra_columns.insert(
866 "metadata".to_string(),
867 ColumnDef {
868 sql_type: "TEXT".to_string(),
869 default_value: None,
870 },
871 );
872 let err2 = cfg2.validate().unwrap_err();
873 assert!(err2.contains("shadows a core column"), "unexpected: {err2}");
874 }
875
876 #[test]
877 fn validate_rejects_unknown_skip_column() {
878 let mut cfg = SchemaConfig::default();
879 cfg.responses
880 .skip_columns
881 .insert("safty_identifier".to_string()); let err = cfg.validate().unwrap_err();
883 assert!(err.contains("not a recognized column"), "unexpected: {err}");
884 }
885
886 #[test]
887 fn validate_rejects_case_colliding_extra_columns() {
888 let mut cfg = SchemaConfig::default();
889 cfg.conversations.extra_columns.insert(
890 "tenant_id".to_string(),
891 ColumnDef {
892 sql_type: "TEXT".to_string(),
893 default_value: None,
894 },
895 );
896 cfg.conversations.extra_columns.insert(
897 "TENANT_ID".to_string(),
898 ColumnDef {
899 sql_type: "TEXT".to_string(),
900 default_value: None,
901 },
902 );
903 let err = cfg.validate().unwrap_err();
904 assert!(
905 err.contains("case-insensitive collision"),
906 "unexpected: {err}"
907 );
908 }
909
910 #[test]
911 fn validate_accepts_conversation_memories_extra_columns() {
912 let mut cfg = SchemaConfig::default();
913 cfg.conversation_memories.extra_columns.insert(
914 "tenant_id".to_string(),
915 ColumnDef {
916 sql_type: "VARCHAR2(128)".to_string(),
917 default_value: None,
918 },
919 );
920 cfg.validate().expect("schema should validate");
921 }
922
923 #[test]
924 fn validate_rejects_conversation_memories_core_column_shadowing() {
925 let mut cfg = SchemaConfig::default();
926 cfg.conversation_memories.extra_columns.insert(
927 "memory_id".to_string(),
928 ColumnDef {
929 sql_type: "VARCHAR2(128)".to_string(),
930 default_value: None,
931 },
932 );
933 let err = cfg.validate().expect_err("shadowing should fail");
934 assert!(err.contains("conversation_memories.extra_columns"));
935 assert!(err.contains("shadows a core column name"));
936 }
937}