Skip to main content

data_connector/
schema.rs

1//! Schema configuration for storage backends.
2//!
3//! Provides YAML-driven customization of table names and column names.
4//! When no schema config is provided, all defaults match the current
5//! hardcoded behavior — zero behavioral change.
6
7use std::collections::{HashMap, HashSet};
8
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12// ────────────────────────────────────────────────────────────────────────────
13// Types
14// ────────────────────────────────────────────────────────────────────────────
15
16/// Top-level schema configuration. Drives all SQL generation and key naming.
17///
18/// Every field has a default matching current hardcoded behavior, so omitting
19/// the entire `schema:` section in YAML produces identical queries to today.
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
21#[serde(default)]
22pub struct SchemaConfig {
23    /// Schema owner / key prefix (e.g. `"ADMIN"` for Oracle, `"myapp"` for Redis).
24    /// The dot in `ADMIN."TABLE"` is generated by `qualified_table()`, not stored here.
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub owner: Option<String>,
27
28    /// Starting schema version. Set this when your database already has
29    /// migrations applied (e.g. `version: 3` skips migrations 1–3).
30    /// `None` means start from 0 (apply all migrations).
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub version: Option<u32>,
33
34    /// Whether to run schema migrations automatically on startup.
35    /// Defaults to `false` (safe by default). When `false` and pending
36    /// migrations are detected, startup fails with the exact SQL statements
37    /// needed so you can review and apply them manually.
38    /// Set to `true` to opt in to automatic migration.
39    #[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}
47
48/// Per-table schema configuration.
49#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
50#[serde(default)]
51pub struct TableConfig {
52    /// Physical table name (or Redis key component).
53    pub table: String,
54
55    /// Column name overrides: `logical_name -> db_column_name`.
56    /// Fields not listed here use their logical name unchanged.
57    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
58    pub columns: HashMap<String, String>,
59
60    /// Extra columns to include in DDL, INSERT, and SELECT statements.
61    /// Populated by hooks at runtime; schema config declares the column
62    /// definitions so backends know the SQL type and default value.
63    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
64    pub extra_columns: HashMap<String, ColumnDef>,
65
66    /// Logical column names to omit from DDL, INSERT, and SELECT.
67    /// Use when the physical schema lacks a standard column
68    /// (e.g. a team's RESPONSES table has no `safety_identifier`).
69    #[serde(default, skip_serializing_if = "HashSet::is_empty")]
70    pub skip_columns: HashSet<String>,
71}
72
73/// Column definition for extra (user-defined) columns declared in schema config.
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
75pub struct ColumnDef {
76    /// SQL type string, e.g. `"VARCHAR2(256)"` or `"TEXT"`.
77    pub sql_type: String,
78
79    /// Default value used on INSERT when no hook provides a value.
80    /// `None` means NULL.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub default_value: Option<Value>,
83}
84
85// ────────────────────────────────────────────────────────────────────────────
86// Defaults
87// ────────────────────────────────────────────────────────────────────────────
88
89fn default_auto_migrate() -> bool {
90    std::env::var("DB_AUTO_MIGRATE")
91        .ok()
92        .map(|v| v.eq_ignore_ascii_case("true") || v == "1")
93        .unwrap_or(false)
94}
95
96impl Default for SchemaConfig {
97    fn default() -> Self {
98        Self {
99            owner: None,
100            version: None,
101            auto_migrate: default_auto_migrate(),
102            conversations: TableConfig::with_table("conversations"),
103            responses: TableConfig::with_table("responses"),
104            conversation_items: TableConfig::with_table("conversation_items"),
105            conversation_item_links: TableConfig::with_table("conversation_item_links"),
106        }
107    }
108}
109
110// ────────────────────────────────────────────────────────────────────────────
111// TableConfig methods
112// ────────────────────────────────────────────────────────────────────────────
113
114impl TableConfig {
115    /// Create a `TableConfig` with the given table name and no column overrides.
116    pub fn with_table(name: &str) -> Self {
117        Self {
118            table: name.to_string(),
119            ..Default::default()
120        }
121    }
122
123    /// Resolve a column name.
124    ///
125    /// Returns the remapped name if an override is configured, or the
126    /// logical field name unchanged otherwise.
127    pub fn col<'a>(&'a self, field: &'a str) -> &'a str {
128        self.columns.get(field).map(String::as_str).unwrap_or(field)
129    }
130
131    /// Returns `true` if `field` is in the `skip_columns` set.
132    ///
133    /// Skipped columns are omitted from DDL, INSERT, and SELECT statements.
134    pub fn is_skipped(&self, field: &str) -> bool {
135        self.skip_columns.contains(field)
136    }
137
138    /// Fully qualified table name, e.g. `ADMIN."MY_TABLE"` with an owner
139    /// or just `my_table` without.
140    ///
141    /// The table name is quoted to preserve case. For Oracle, call
142    /// `SchemaConfig::uppercase_for_oracle()` first so the quoted names match
143    /// Oracle's uppercase catalog entries.
144    pub fn qualified_table(&self, owner: Option<&str>) -> String {
145        match owner {
146            Some(o) => format!("{o}.\"{}\"", self.table),
147            None => self.table.clone(),
148        }
149    }
150}
151
152// ────────────────────────────────────────────────────────────────────────────
153// Validation
154// ────────────────────────────────────────────────────────────────────────────
155
156impl SchemaConfig {
157    /// Uppercase all table names and column override values in-place.
158    ///
159    /// Oracle folds unquoted identifiers to uppercase, so existing tables and
160    /// columns are stored as `CONVERSATIONS`, `CONV_ID`, etc.
161    /// `qualified_table()` quotes identifiers (`OWNER."table"`), making them
162    /// case-sensitive.  Calling this in `OracleStore::new()` ensures the
163    /// quoted names match the actual uppercase catalog entries Oracle created.
164    pub fn uppercase_for_oracle(&mut self) {
165        for tc in [
166            &mut self.conversations,
167            &mut self.responses,
168            &mut self.conversation_items,
169            &mut self.conversation_item_links,
170        ] {
171            tc.table.make_ascii_uppercase();
172            for val in tc.columns.values_mut() {
173                val.make_ascii_uppercase();
174            }
175            // Uppercase extra column keys (the physical column names in Oracle).
176            // We must collect keys first to avoid borrowing conflicts.
177            let keys: Vec<String> = tc.extra_columns.keys().cloned().collect();
178            for key in keys {
179                if let Some(def) = tc.extra_columns.remove(&key) {
180                    tc.extra_columns.insert(key.to_ascii_uppercase(), def);
181                }
182            }
183            // NOTE: skip_columns are NOT uppercased. They are logical field
184            // names used in is_skipped() checks throughout the backends, and
185            // callers always pass lowercase literals (e.g. "safety_identifier").
186            // Uppercasing them would break the case-sensitive HashSet lookup.
187        }
188    }
189
190    /// Validate the entire schema config at startup. Rejects invalid identifiers.
191    pub fn validate(&self) -> Result<(), String> {
192        // Validate owner
193        if let Some(ref owner) = self.owner {
194            validate_identifier(owner).map_err(|e| format!("owner: {e}"))?;
195        }
196
197        // Validate each table config
198        Self::validate_table("conversations", &self.conversations)?;
199        Self::validate_table("responses", &self.responses)?;
200        Self::validate_table("conversation_items", &self.conversation_items)?;
201        Self::validate_table("conversation_item_links", &self.conversation_item_links)?;
202
203        Ok(())
204    }
205
206    fn validate_table(label: &str, tc: &TableConfig) -> Result<(), String> {
207        validate_identifier(&tc.table).map_err(|e| {
208            if tc.table.is_empty() {
209                format!("{label}.table: table name is required (got empty string — did you omit the 'table' key in your config?)")
210            } else {
211                format!("{label}.table: {e}")
212            }
213        })?;
214
215        for (logical, physical) in &tc.columns {
216            validate_identifier(logical)
217                .map_err(|e| format!("{label}.columns key '{logical}': {e}"))?;
218            validate_identifier(physical)
219                .map_err(|e| format!("{label}.columns value '{physical}': {e}"))?;
220        }
221
222        for (name, def) in &tc.extra_columns {
223            validate_identifier(name)
224                .map_err(|e| format!("{label}.extra_columns key '{name}': {e}"))?;
225            if def.sql_type.is_empty() {
226                return Err(format!(
227                    "{label}.extra_columns['{name}']: sql_type must not be empty"
228                ));
229            }
230            validate_sql_type(&def.sql_type)
231                .map_err(|e| format!("{label}.extra_columns['{name}'].sql_type: {e}"))?;
232        }
233
234        // Reject extra_columns that shadow core column names (case-insensitive,
235        // since Oracle normalizes to uppercase)
236        let core = core_columns_for(label);
237        for name in tc.extra_columns.keys() {
238            let upper = name.to_ascii_uppercase();
239            if core.iter().any(|c| c.to_ascii_uppercase() == upper) {
240                return Err(format!(
241                    "{label}.extra_columns: '{name}' shadows a core column name"
242                ));
243            }
244        }
245
246        // Detect case-insensitive collisions between extra_columns keys
247        // (e.g. "tenant_id" and "TENANT_ID" would collide after uppercase_for_oracle)
248        let mut folded: HashSet<String> = HashSet::new();
249        for name in tc.extra_columns.keys() {
250            let upper = name.to_ascii_uppercase();
251            if !folded.insert(upper) {
252                return Err(format!(
253                    "{label}.extra_columns: case-insensitive collision on '{name}' \
254                     (Oracle normalizes identifiers to uppercase)"
255                ));
256            }
257        }
258
259        for name in &tc.skip_columns {
260            validate_identifier(name).map_err(|e| format!("{label}.skip_columns '{name}': {e}"))?;
261            if primary_key_columns_for(label).contains(&name.as_str()) {
262                return Err(format!(
263                    "{label}.skip_columns: cannot skip '{name}' — it is part of the primary key"
264                ));
265            }
266            if !core.contains(&name.as_str()) {
267                return Err(format!(
268                    "{label}.skip_columns: '{name}' is not a recognized column \
269                     (known: {core:?})"
270                ));
271            }
272        }
273
274        Ok(())
275    }
276}
277
278fn primary_key_columns_for(_label: &str) -> &'static [&'static str] {
279    &["id"]
280}
281
282/// Core logical column names for each table, used by validation to reject
283/// `extra_columns` that would shadow built-in fields.
284fn core_columns_for(label: &str) -> &'static [&'static str] {
285    match label {
286        "conversations" => &["id", "created_at", "metadata"],
287        "responses" => &[
288            "id",
289            "conversation_id",
290            "previous_response_id",
291            "input",
292            "created_at",
293            "safety_identifier",
294            "model",
295            "raw_response",
296        ],
297        "conversation_items" => &[
298            "id",
299            "response_id",
300            "item_type",
301            "role",
302            "content",
303            "status",
304            "created_at",
305        ],
306        "conversation_item_links" => &["conversation_id", "item_id", "added_at"],
307        _ => &[],
308    }
309}
310
311/// Maximum identifier length. Oracle caps at 30 (pre-12.2) or 128,
312/// Postgres at 63. We use 128 as a generous upper bound.
313const MAX_IDENTIFIER_LEN: usize = 128;
314
315/// Reject identifiers that are empty, too long, or contain characters outside `[a-zA-Z0-9_]`.
316fn validate_identifier(name: &str) -> Result<(), String> {
317    if name.is_empty() {
318        return Err("identifier must not be empty".to_string());
319    }
320    if name.len() > MAX_IDENTIFIER_LEN {
321        return Err(format!(
322            "identifier '{name}' exceeds maximum length of {MAX_IDENTIFIER_LEN} characters"
323        ));
324    }
325    if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
326        return Err(format!(
327            "invalid identifier '{name}' — only ASCII alphanumeric and underscores allowed"
328        ));
329    }
330    Ok(())
331}
332
333/// Maximum length for a `sql_type` string in a `ColumnDef`.
334const MAX_SQL_TYPE_LEN: usize = 64;
335
336/// Validate a `sql_type` string for use in DDL.
337///
338/// Accepts only characters needed for standard SQL type declarations:
339/// alphanumeric, underscore, space, parentheses, comma, period.
340/// Examples: `VARCHAR(128)`, `TIMESTAMP WITH TIME ZONE`, `NUMBER(10,2)`.
341fn validate_sql_type(sql_type: &str) -> Result<(), String> {
342    if sql_type.trim().is_empty() {
343        return Err("sql_type must not be whitespace-only".to_string());
344    }
345    if sql_type.len() > MAX_SQL_TYPE_LEN {
346        return Err(format!(
347            "sql_type '{sql_type}' exceeds maximum length of {MAX_SQL_TYPE_LEN} characters"
348        ));
349    }
350    if !sql_type
351        .chars()
352        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | ' ' | '(' | ')' | ',' | '.'))
353    {
354        return Err(format!(
355            "invalid sql_type '{sql_type}' — only ASCII alphanumeric, underscore, space, \
356             parentheses, comma, and period are allowed"
357        ));
358    }
359    Ok(())
360}
361
362// ────────────────────────────────────────────────────────────────────────────
363// Tests
364// ────────────────────────────────────────────────────────────────────────────
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    // ── Default config ────────────────────────────────────────────────────
371
372    #[test]
373    fn default_config_matches_hardcoded_names() {
374        let cfg = SchemaConfig::default();
375        assert_eq!(cfg.conversations.table, "conversations");
376        assert_eq!(cfg.responses.table, "responses");
377        assert_eq!(cfg.conversation_items.table, "conversation_items");
378        assert_eq!(cfg.conversation_item_links.table, "conversation_item_links");
379        assert!(cfg.owner.is_none());
380    }
381
382    #[test]
383    fn default_config_validates_successfully() {
384        SchemaConfig::default()
385            .validate()
386            .expect("default config should be valid");
387    }
388
389    // ── col() ─────────────────────────────────────────────────────────────
390
391    #[test]
392    fn col_returns_field_name_when_no_override() {
393        let tc = TableConfig::with_table("t");
394        assert_eq!(tc.col("id"), "id");
395        assert_eq!(tc.col("created_at"), "created_at");
396    }
397
398    #[test]
399    fn col_returns_override_when_configured() {
400        let mut tc = TableConfig::with_table("t");
401        tc.columns
402            .insert("id".to_string(), "CONVERSATION_ID".to_string());
403        assert_eq!(tc.col("id"), "CONVERSATION_ID");
404        // Non-overridden field still returns itself
405        assert_eq!(tc.col("created_at"), "created_at");
406    }
407
408    // ── qualified_table() ─────────────────────────────────────────────────
409
410    #[test]
411    fn qualified_table_without_owner() {
412        let tc = TableConfig::with_table("conversations");
413        assert_eq!(tc.qualified_table(None), "conversations");
414    }
415
416    #[test]
417    fn qualified_table_with_owner() {
418        let tc = TableConfig::with_table("CONVERSATIONS");
419        assert_eq!(tc.qualified_table(Some("ADMIN")), "ADMIN.\"CONVERSATIONS\"");
420    }
421
422    // ── uppercase_for_oracle() ──────────────────────────────────────────
423
424    #[test]
425    fn uppercase_for_oracle_converts_defaults() {
426        let mut cfg = SchemaConfig::default();
427        cfg.uppercase_for_oracle();
428        assert_eq!(cfg.conversations.table, "CONVERSATIONS");
429        assert_eq!(cfg.responses.table, "RESPONSES");
430        assert_eq!(cfg.conversation_items.table, "CONVERSATION_ITEMS");
431        assert_eq!(cfg.conversation_item_links.table, "CONVERSATION_ITEM_LINKS");
432        cfg.validate().expect("uppercased config should be valid");
433    }
434
435    #[test]
436    fn uppercase_for_oracle_converts_custom_table_and_columns() {
437        let mut cfg = SchemaConfig::default();
438        cfg.conversations.table = "my_convos".to_string();
439        cfg.conversations
440            .columns
441            .insert("id".to_string(), "conv_id".to_string());
442        cfg.uppercase_for_oracle();
443        assert_eq!(cfg.conversations.table, "MY_CONVOS");
444        assert_eq!(cfg.conversations.col("id"), "CONV_ID");
445    }
446
447    // ── validate() ────────────────────────────────────────────────────────
448
449    #[test]
450    fn validate_accepts_valid_identifiers() {
451        let mut cfg = SchemaConfig {
452            owner: Some("ADMIN_01".to_string()),
453            ..Default::default()
454        };
455        cfg.conversations.table = "MY_CONVERSATIONS".to_string();
456        cfg.conversations
457            .columns
458            .insert("id".to_string(), "CONV_ID".to_string());
459        cfg.validate().expect("should be valid");
460    }
461
462    #[test]
463    fn validate_rejects_empty_table_name_with_helpful_message() {
464        let mut cfg = SchemaConfig::default();
465        cfg.conversations.table = String::new();
466        let err = cfg.validate().unwrap_err();
467        assert!(
468            err.contains("conversations.table") && err.contains("table name is required"),
469            "unexpected: {err}"
470        );
471    }
472
473    #[test]
474    fn validate_rejects_overly_long_identifier() {
475        let mut cfg = SchemaConfig::default();
476        cfg.conversations.table = "a".repeat(129);
477        let err = cfg.validate().unwrap_err();
478        assert!(err.contains("exceeds maximum length"), "unexpected: {err}");
479    }
480
481    #[test]
482    fn validate_rejects_special_characters() {
483        let mut cfg = SchemaConfig::default();
484        cfg.conversations.table = "table;DROP".to_string();
485        let err = cfg.validate().unwrap_err();
486        assert!(
487            err.contains("conversations.table") && err.contains("invalid identifier"),
488            "unexpected: {err}"
489        );
490    }
491
492    #[test]
493    fn validate_rejects_dots_in_owner() {
494        let cfg = SchemaConfig {
495            owner: Some("ADMIN.SCHEMA".to_string()),
496            ..Default::default()
497        };
498        let err = cfg.validate().unwrap_err();
499        assert!(
500            err.contains("owner") && err.contains("invalid identifier"),
501            "unexpected: {err}"
502        );
503    }
504
505    #[test]
506    fn validate_rejects_invalid_column_override_key() {
507        let mut cfg = SchemaConfig::default();
508        cfg.responses
509            .columns
510            .insert("bad;col".to_string(), "GOOD_COL".to_string());
511        let err = cfg.validate().unwrap_err();
512        assert!(
513            err.contains("columns key") && err.contains("invalid identifier"),
514            "unexpected: {err}"
515        );
516    }
517
518    #[test]
519    fn validate_rejects_invalid_column_override_value() {
520        let mut cfg = SchemaConfig::default();
521        cfg.responses
522            .columns
523            .insert("id".to_string(), "bad column!".to_string());
524        let err = cfg.validate().unwrap_err();
525        assert!(
526            err.contains("columns value") && err.contains("invalid identifier"),
527            "unexpected: {err}"
528        );
529    }
530
531    // ── Serde roundtrip ───────────────────────────────────────────────────
532
533    #[test]
534    fn serde_roundtrip_default() {
535        let cfg = SchemaConfig::default();
536        let json = serde_json::to_string(&cfg).expect("serialize");
537        let restored: SchemaConfig = serde_json::from_str(&json).expect("deserialize");
538        assert_eq!(cfg, restored);
539    }
540
541    #[test]
542    fn serde_roundtrip_custom() {
543        let mut cfg = SchemaConfig {
544            owner: Some("ADMIN".to_string()),
545            ..Default::default()
546        };
547        cfg.conversations.table = "CONVERSATIONS".to_string();
548        cfg.conversations
549            .columns
550            .insert("id".to_string(), "CONVERSATION_ID".to_string());
551
552        let json = serde_json::to_string(&cfg).expect("serialize");
553        let restored: SchemaConfig = serde_json::from_str(&json).expect("deserialize");
554        assert_eq!(cfg, restored);
555    }
556
557    #[test]
558    fn serde_deserialize_empty_object_uses_defaults() {
559        let cfg: SchemaConfig = serde_json::from_str("{}").expect("deserialize empty");
560        assert_eq!(cfg, SchemaConfig::default());
561    }
562
563    // ── version / auto_migrate serde ────────────────────────────────────
564
565    #[test]
566    fn serde_roundtrip_with_version_and_auto_migrate() {
567        let cfg = SchemaConfig {
568            version: Some(3),
569            auto_migrate: false,
570            ..Default::default()
571        };
572        let json = serde_json::to_string(&cfg).expect("serialize");
573        let restored: SchemaConfig = serde_json::from_str(&json).expect("deserialize");
574        assert_eq!(restored.version, Some(3));
575        assert!(!restored.auto_migrate);
576    }
577
578    #[test]
579    fn serde_defaults_version_none_and_auto_migrate_false() {
580        let cfg: SchemaConfig = serde_json::from_str("{}").expect("deserialize empty");
581        assert_eq!(cfg.version, None);
582        assert!(!cfg.auto_migrate);
583    }
584
585    #[test]
586    fn serde_version_none_is_omitted_from_json() {
587        let cfg = SchemaConfig::default();
588        let json = serde_json::to_string(&cfg).expect("serialize");
589        assert!(
590            !json.contains("version"),
591            "version:None should be skipped: {json}"
592        );
593    }
594
595    // ── is_skipped() ─────────────────────────────────────────────────────
596
597    #[test]
598    fn is_skipped_returns_false_by_default() {
599        let tc = TableConfig::with_table("t");
600        assert!(!tc.is_skipped("id"));
601        assert!(!tc.is_skipped("safety_identifier"));
602    }
603
604    #[test]
605    fn is_skipped_returns_true_for_configured_fields() {
606        let mut tc = TableConfig::with_table("t");
607        tc.skip_columns.insert("safety_identifier".to_string());
608        tc.skip_columns.insert("raw_response".to_string());
609        assert!(tc.is_skipped("safety_identifier"));
610        assert!(tc.is_skipped("raw_response"));
611        assert!(!tc.is_skipped("id"));
612    }
613
614    // ── extra_columns ────────────────────────────────────────────────────
615
616    #[test]
617    fn extra_columns_default_is_empty() {
618        let tc = TableConfig::with_table("t");
619        assert!(tc.extra_columns.is_empty());
620    }
621
622    #[test]
623    fn validate_accepts_valid_extra_columns() {
624        let mut cfg = SchemaConfig::default();
625        cfg.conversations.extra_columns.insert(
626            "EXPIRES_AT".to_string(),
627            ColumnDef {
628                sql_type: "TIMESTAMP".to_string(),
629                default_value: None,
630            },
631        );
632        cfg.validate().expect("should be valid");
633    }
634
635    #[test]
636    fn validate_rejects_invalid_extra_column_name() {
637        let mut cfg = SchemaConfig::default();
638        cfg.conversations.extra_columns.insert(
639            "bad;col".to_string(),
640            ColumnDef {
641                sql_type: "TEXT".to_string(),
642                default_value: None,
643            },
644        );
645        let err = cfg.validate().unwrap_err();
646        assert!(
647            err.contains("extra_columns key") && err.contains("invalid identifier"),
648            "unexpected: {err}"
649        );
650    }
651
652    #[test]
653    fn validate_rejects_empty_sql_type() {
654        let mut cfg = SchemaConfig::default();
655        cfg.conversations.extra_columns.insert(
656            "MY_COL".to_string(),
657            ColumnDef {
658                sql_type: String::new(),
659                default_value: None,
660            },
661        );
662        let err = cfg.validate().unwrap_err();
663        assert!(
664            err.contains("sql_type must not be empty"),
665            "unexpected: {err}"
666        );
667    }
668
669    #[test]
670    fn validate_rejects_invalid_skip_column_name() {
671        let mut cfg = SchemaConfig::default();
672        cfg.responses.skip_columns.insert("bad;col".to_string());
673        let err = cfg.validate().unwrap_err();
674        assert!(
675            err.contains("skip_columns") && err.contains("invalid identifier"),
676            "unexpected: {err}"
677        );
678    }
679
680    #[test]
681    fn validate_accepts_valid_skip_columns() {
682        let mut cfg = SchemaConfig::default();
683        cfg.responses
684            .skip_columns
685            .insert("safety_identifier".to_string());
686        cfg.validate().expect("should be valid");
687    }
688
689    #[test]
690    fn validate_rejects_skip_id() {
691        let mut cfg = SchemaConfig::default();
692        cfg.responses.skip_columns.insert("id".to_string());
693        let err = cfg.validate().unwrap_err();
694        assert!(
695            err.contains("cannot skip 'id'") && err.contains("primary key"),
696            "unexpected: {err}"
697        );
698    }
699
700    // ── validate_sql_type ─────────────────────────────────────────────────
701
702    #[test]
703    fn validate_accepts_valid_sql_types() {
704        let mut cfg = SchemaConfig::default();
705        for sql_type in [
706            "VARCHAR(128)",
707            "TEXT",
708            "TIMESTAMP",
709            "TIMESTAMP WITH TIME ZONE",
710            "NUMBER(10,2)",
711            "VARCHAR2(256)",
712            "BIGINT",
713        ] {
714            cfg.conversations.extra_columns.insert(
715                "TEST_COL".to_string(),
716                ColumnDef {
717                    sql_type: sql_type.to_string(),
718                    default_value: None,
719                },
720            );
721            cfg.validate()
722                .unwrap_or_else(|e| panic!("sql_type '{sql_type}' should be valid: {e}"));
723        }
724    }
725
726    #[test]
727    fn validate_rejects_sql_injection_in_sql_type() {
728        let mut cfg = SchemaConfig::default();
729        cfg.conversations.extra_columns.insert(
730            "MY_COL".to_string(),
731            ColumnDef {
732                sql_type: "TEXT); DROP TABLE responses; --".to_string(),
733                default_value: None,
734            },
735        );
736        let err = cfg.validate().unwrap_err();
737        assert!(
738            err.contains("sql_type") && err.contains("invalid"),
739            "unexpected: {err}"
740        );
741    }
742
743    #[test]
744    fn validate_rejects_overly_long_sql_type() {
745        let mut cfg = SchemaConfig::default();
746        cfg.conversations.extra_columns.insert(
747            "MY_COL".to_string(),
748            ColumnDef {
749                sql_type: "A".repeat(65),
750                default_value: None,
751            },
752        );
753        let err = cfg.validate().unwrap_err();
754        assert!(
755            err.contains("sql_type") && err.contains("exceeds maximum length"),
756            "unexpected: {err}"
757        );
758    }
759
760    // ── uppercase_for_oracle with extra_columns ──────────────────────────
761
762    #[test]
763    fn uppercase_for_oracle_converts_extra_column_keys() {
764        let mut cfg = SchemaConfig::default();
765        cfg.conversations.extra_columns.insert(
766            "expires_at".to_string(),
767            ColumnDef {
768                sql_type: "TIMESTAMP".to_string(),
769                default_value: None,
770            },
771        );
772        cfg.uppercase_for_oracle();
773        assert!(cfg.conversations.extra_columns.contains_key("EXPIRES_AT"));
774        assert!(!cfg.conversations.extra_columns.contains_key("expires_at"));
775    }
776
777    #[test]
778    fn uppercase_for_oracle_preserves_skip_column_names_lowercase() {
779        let mut cfg = SchemaConfig::default();
780        cfg.responses
781            .skip_columns
782            .insert("safety_identifier".to_string());
783        cfg.uppercase_for_oracle();
784        // skip_columns must stay lowercase — backends call is_skipped("safety_identifier")
785        assert!(cfg.responses.skip_columns.contains("safety_identifier"));
786        assert!(!cfg.responses.skip_columns.contains("SAFETY_IDENTIFIER"));
787    }
788
789    // ── Serde roundtrip with extra_columns and skip_columns ──────────────
790
791    #[test]
792    fn serde_roundtrip_with_extra_and_skip() {
793        let mut cfg = SchemaConfig::default();
794        cfg.conversations.extra_columns.insert(
795            "EXPIRES_AT".to_string(),
796            ColumnDef {
797                sql_type: "TIMESTAMP".to_string(),
798                default_value: Some(Value::String("2099-01-01".to_string())),
799            },
800        );
801        cfg.responses
802            .skip_columns
803            .insert("safety_identifier".to_string());
804
805        let json = serde_json::to_string(&cfg).expect("serialize");
806        let restored: SchemaConfig = serde_json::from_str(&json).expect("deserialize");
807        assert_eq!(cfg, restored);
808    }
809
810    #[test]
811    fn validate_rejects_extra_column_shadowing_core_column() {
812        let mut cfg = SchemaConfig::default();
813        cfg.responses.extra_columns.insert(
814            "CREATED_AT".to_string(),
815            ColumnDef {
816                sql_type: "TIMESTAMP".to_string(),
817                default_value: None,
818            },
819        );
820        let err = cfg.validate().unwrap_err();
821        assert!(err.contains("shadows a core column"), "unexpected: {err}");
822
823        // Also check a conversation core column
824        let mut cfg2 = SchemaConfig::default();
825        cfg2.conversations.extra_columns.insert(
826            "metadata".to_string(),
827            ColumnDef {
828                sql_type: "TEXT".to_string(),
829                default_value: None,
830            },
831        );
832        let err2 = cfg2.validate().unwrap_err();
833        assert!(err2.contains("shadows a core column"), "unexpected: {err2}");
834    }
835
836    #[test]
837    fn validate_rejects_unknown_skip_column() {
838        let mut cfg = SchemaConfig::default();
839        cfg.responses
840            .skip_columns
841            .insert("safty_identifier".to_string()); // typo
842        let err = cfg.validate().unwrap_err();
843        assert!(err.contains("not a recognized column"), "unexpected: {err}");
844    }
845
846    #[test]
847    fn validate_rejects_case_colliding_extra_columns() {
848        let mut cfg = SchemaConfig::default();
849        cfg.conversations.extra_columns.insert(
850            "tenant_id".to_string(),
851            ColumnDef {
852                sql_type: "TEXT".to_string(),
853                default_value: None,
854            },
855        );
856        cfg.conversations.extra_columns.insert(
857            "TENANT_ID".to_string(),
858            ColumnDef {
859                sql_type: "TEXT".to_string(),
860                default_value: None,
861            },
862        );
863        let err = cfg.validate().unwrap_err();
864        assert!(
865            err.contains("case-insensitive collision"),
866            "unexpected: {err}"
867        );
868    }
869}