Skip to main content

cipherstash_config/
canonical.rs

1use std::collections::HashMap;
2use std::str::FromStr;
3
4use serde::{Deserialize, Serialize};
5
6use crate::column::index::{K_DEFAULT, M_DEFAULT};
7use crate::column::{ArrayIndexMode, Index, IndexType, SteVecMode, TokenFilter, Tokenizer};
8use crate::errors::ConfigError;
9use crate::{ColumnConfig, ColumnType};
10
11/// The canonical plaintext type for a column in user-facing JSON config.
12///
13/// Maps to the internal [`ColumnType`] representation.
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum PlaintextType {
17    BigInt,
18    Boolean,
19    Date,
20    Decimal,
21    #[serde(alias = "real", alias = "double")]
22    Float,
23    Int,
24    #[serde(rename = "json", alias = "jsonb")]
25    Json,
26    SmallInt,
27    #[default]
28    Text,
29    Timestamp,
30}
31
32impl std::fmt::Display for PlaintextType {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        match self {
35            Self::BigInt => write!(f, "big_int"),
36            Self::Boolean => write!(f, "boolean"),
37            Self::Date => write!(f, "date"),
38            Self::Decimal => write!(f, "decimal"),
39            Self::Float => write!(f, "float"),
40            Self::Int => write!(f, "int"),
41            Self::Json => write!(f, "json"),
42            Self::SmallInt => write!(f, "small_int"),
43            Self::Text => write!(f, "text"),
44            Self::Timestamp => write!(f, "timestamp"),
45        }
46    }
47}
48
49impl From<PlaintextType> for ColumnType {
50    fn from(pt: PlaintextType) -> Self {
51        match pt {
52            PlaintextType::BigInt => ColumnType::BigInt,
53            PlaintextType::Boolean => ColumnType::Boolean,
54            PlaintextType::Date => ColumnType::Date,
55            PlaintextType::Decimal => ColumnType::Decimal,
56            PlaintextType::Float => ColumnType::Float,
57            PlaintextType::Int => ColumnType::Int,
58            PlaintextType::Json => ColumnType::Json,
59            PlaintextType::SmallInt => ColumnType::SmallInt,
60            PlaintextType::Text => ColumnType::Text,
61            PlaintextType::Timestamp => ColumnType::Timestamp,
62        }
63    }
64}
65
66/// Stable identifier for a table/column pair, used as a key in config maps.
67#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
68pub struct Identifier {
69    #[serde(rename = "t")]
70    pub table: String,
71    #[serde(rename = "c")]
72    pub column: String,
73}
74
75impl Identifier {
76    pub fn new(table: impl Into<String>, column: impl Into<String>) -> Self {
77        Self {
78            table: table.into(),
79            column: column.into(),
80        }
81    }
82}
83
84impl std::fmt::Display for Identifier {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        write!(f, "{}.{}", self.table, self.column)
87    }
88}
89
90/// Top-level canonical encryption configuration.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct CanonicalEncryptionConfig {
93    #[serde(rename = "v")]
94    pub version: u32,
95    pub tables: Tables,
96}
97
98/// A mapping of table names to their column encryption configurations.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct Tables(pub HashMap<String, Table>);
101
102/// A mapping of column names to their encryption configuration within a single table.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct Table(pub HashMap<String, Column>);
105
106#[derive(Debug, Clone, Default, Serialize, Deserialize)]
107pub struct Column {
108    #[serde(default, alias = "cast_as")]
109    pub plaintext_type: PlaintextType,
110    #[serde(default)]
111    pub indexes: Indexes,
112}
113
114#[derive(Debug, Clone, Default, Serialize, Deserialize)]
115pub struct Indexes {
116    pub ore: Option<OreIndexOpts>,
117    pub ope: Option<OpeIndexOpts>,
118    pub unique: Option<UniqueIndexOpts>,
119    #[serde(rename = "match")]
120    pub match_index: Option<MatchIndexOpts>,
121    pub ste_vec: Option<SteVecIndexOpts>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct OreIndexOpts {}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct OpeIndexOpts {}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct UniqueIndexOpts {
132    #[serde(default)]
133    pub token_filters: Vec<TokenFilter>,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct MatchIndexOpts {
138    #[serde(default = "default_tokenizer")]
139    pub tokenizer: Tokenizer,
140    #[serde(default)]
141    pub token_filters: Vec<TokenFilter>,
142    #[serde(default = "default_k")]
143    pub k: usize,
144    #[serde(default = "default_m")]
145    pub m: usize,
146    #[serde(default)]
147    pub include_original: bool,
148}
149
150impl Default for MatchIndexOpts {
151    fn default() -> Self {
152        Self {
153            tokenizer: Tokenizer::Standard,
154            token_filters: vec![],
155            k: K_DEFAULT,
156            m: M_DEFAULT,
157            include_original: false,
158        }
159    }
160}
161
162fn default_tokenizer() -> Tokenizer {
163    Tokenizer::Standard
164}
165
166fn default_k() -> usize {
167    K_DEFAULT
168}
169
170fn default_m() -> usize {
171    M_DEFAULT
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct SteVecIndexOpts {
176    pub prefix: String,
177    #[serde(default)]
178    pub term_filters: Vec<TokenFilter>,
179    #[serde(default = "default_array_index_mode")]
180    pub array_index_mode: ArrayIndexMode,
181    #[serde(default)]
182    pub mode: SteVecMode,
183}
184
185fn default_array_index_mode() -> ArrayIndexMode {
186    ArrayIndexMode::ALL
187}
188
189impl FromStr for CanonicalEncryptionConfig {
190    type Err = ConfigError;
191
192    fn from_str(s: &str) -> Result<Self, Self::Err> {
193        serde_json::from_str(s).map_err(|e| ConfigError::ParseError(e.to_string()))
194    }
195}
196
197impl CanonicalEncryptionConfig {
198    pub fn into_config_map(self) -> Result<HashMap<Identifier, ColumnConfig>, ConfigError> {
199        if self.version != 1 {
200            return Err(ConfigError::UnsupportedVersion {
201                version: self.version,
202                expected: 1,
203            });
204        }
205
206        let mut map = HashMap::new();
207
208        for (table_name, table) in self.tables.0 {
209            for (column_name, column) in table.0 {
210                let identifier = Identifier::new(&table_name, &column_name);
211                let config = column.into_column_config(&table_name, &column_name)?;
212                map.insert(identifier, config);
213            }
214        }
215
216        Ok(map)
217    }
218}
219
220impl Column {
221    fn into_column_config(
222        self,
223        table_name: &str,
224        column_name: &str,
225    ) -> Result<ColumnConfig, ConfigError> {
226        let column_type: ColumnType = self.plaintext_type.into();
227
228        if self.indexes.ste_vec.is_some() && self.plaintext_type != PlaintextType::Json {
229            return Err(ConfigError::SteVecRequiresJson {
230                table: table_name.to_owned(),
231                column: column_name.to_owned(),
232                found_plaintext_type: self.plaintext_type.to_string(),
233            });
234        }
235
236        if self.indexes.match_index.is_some() && self.plaintext_type != PlaintextType::Text {
237            return Err(ConfigError::MatchRequiresText {
238                table: table_name.to_owned(),
239                column: column_name.to_owned(),
240                found_plaintext_type: self.plaintext_type.to_string(),
241            });
242        }
243
244        let mut config = ColumnConfig::build(column_name).casts_as(column_type);
245
246        if self.indexes.ore.is_some() {
247            config = config.add_index(Index::new_ore());
248        }
249
250        if self.indexes.ope.is_some() {
251            config = config.add_index(Index::new_ope());
252        }
253
254        if let Some(unique_opts) = self.indexes.unique {
255            config = config.add_index(Index::new(IndexType::Unique {
256                token_filters: unique_opts.token_filters,
257            }));
258        }
259
260        if let Some(match_opts) = self.indexes.match_index {
261            config = config.add_index(Index::new(IndexType::Match {
262                tokenizer: match_opts.tokenizer,
263                token_filters: match_opts.token_filters,
264                k: match_opts.k,
265                m: match_opts.m,
266                include_original: match_opts.include_original,
267            }));
268        }
269
270        if let Some(ste_vec_opts) = self.indexes.ste_vec {
271            config = config.add_index(Index::new(IndexType::SteVec {
272                prefix: ste_vec_opts.prefix,
273                term_filters: ste_vec_opts.term_filters,
274                array_index_mode: ste_vec_opts.array_index_mode,
275                mode: ste_vec_opts.mode,
276            }));
277        }
278
279        Ok(config)
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use serde_json::json;
287
288    #[test]
289    fn it_deserializes_all_plaintext_types() {
290        let cases = vec![
291            ("text", PlaintextType::Text),
292            ("int", PlaintextType::Int),
293            ("small_int", PlaintextType::SmallInt),
294            ("big_int", PlaintextType::BigInt),
295            ("float", PlaintextType::Float),
296            ("boolean", PlaintextType::Boolean),
297            ("date", PlaintextType::Date),
298            ("json", PlaintextType::Json),
299            ("decimal", PlaintextType::Decimal),
300            ("timestamp", PlaintextType::Timestamp),
301        ];
302
303        for (input, expected) in cases {
304            let result: PlaintextType = serde_json::from_value(json!(input)).unwrap();
305            assert_eq!(result, expected, "Failed for input: {input}");
306        }
307    }
308
309    #[test]
310    fn it_defaults_to_text() {
311        let pt: PlaintextType = Default::default();
312        assert_eq!(pt, PlaintextType::Text);
313    }
314
315    #[test]
316    fn it_accepts_jsonb_alias() {
317        let result: PlaintextType = serde_json::from_value(json!("jsonb")).unwrap();
318        assert_eq!(result, PlaintextType::Json);
319    }
320
321    #[test]
322    fn it_accepts_real_alias() {
323        let result: PlaintextType = serde_json::from_value(json!("real")).unwrap();
324        assert_eq!(result, PlaintextType::Float);
325    }
326
327    #[test]
328    fn it_accepts_double_alias() {
329        let result: PlaintextType = serde_json::from_value(json!("double")).unwrap();
330        assert_eq!(result, PlaintextType::Float);
331    }
332
333    #[test]
334    fn it_converts_to_column_type() {
335        assert_eq!(ColumnType::from(PlaintextType::Text), ColumnType::Text);
336        assert_eq!(ColumnType::from(PlaintextType::Int), ColumnType::Int);
337        assert_eq!(
338            ColumnType::from(PlaintextType::SmallInt),
339            ColumnType::SmallInt
340        );
341        assert_eq!(ColumnType::from(PlaintextType::BigInt), ColumnType::BigInt);
342        assert_eq!(ColumnType::from(PlaintextType::Float), ColumnType::Float);
343        assert_eq!(
344            ColumnType::from(PlaintextType::Boolean),
345            ColumnType::Boolean
346        );
347        assert_eq!(ColumnType::from(PlaintextType::Date), ColumnType::Date);
348        assert_eq!(ColumnType::from(PlaintextType::Json), ColumnType::Json);
349        assert_eq!(
350            ColumnType::from(PlaintextType::Decimal),
351            ColumnType::Decimal
352        );
353        assert_eq!(
354            ColumnType::from(PlaintextType::Timestamp),
355            ColumnType::Timestamp
356        );
357    }
358
359    #[test]
360    fn it_serializes_to_canonical_names() {
361        assert_eq!(
362            serde_json::to_value(PlaintextType::Text).unwrap(),
363            json!("text")
364        );
365        assert_eq!(
366            serde_json::to_value(PlaintextType::Json).unwrap(),
367            json!("json")
368        );
369        assert_eq!(
370            serde_json::to_value(PlaintextType::BigInt).unwrap(),
371            json!("big_int")
372        );
373    }
374
375    #[test]
376    fn it_parses_minimal_config() {
377        let input = json!({
378            "v": 1,
379            "tables": {
380                "users": {
381                    "email": {}
382                }
383            }
384        });
385
386        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
387        assert_eq!(config.version, 1);
388    }
389
390    #[test]
391    fn it_accepts_cast_as_field_name() {
392        let input = json!({
393            "v": 1,
394            "tables": {
395                "users": {
396                    "email": {
397                        "cast_as": "int"
398                    }
399                }
400            }
401        });
402
403        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
404        let table = config.tables.0.get("users").unwrap();
405        let column = table.0.get("email").unwrap();
406        assert_eq!(column.plaintext_type, PlaintextType::Int);
407    }
408
409    #[test]
410    fn it_accepts_plaintext_type_field_name() {
411        let input = json!({
412            "v": 1,
413            "tables": {
414                "users": {
415                    "email": {
416                        "plaintext_type": "int"
417                    }
418                }
419            }
420        });
421
422        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
423        let table = config.tables.0.get("users").unwrap();
424        let column = table.0.get("email").unwrap();
425        assert_eq!(column.plaintext_type, PlaintextType::Int);
426    }
427
428    #[test]
429    fn it_parses_ore_index() {
430        let input = json!({
431            "v": 1,
432            "tables": {
433                "users": {
434                    "age": {
435                        "plaintext_type": "int",
436                        "indexes": { "ore": {} }
437                    }
438                }
439            }
440        });
441
442        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
443        let col = config.tables.0.get("users").unwrap().0.get("age").unwrap();
444        assert!(col.indexes.ore.is_some());
445    }
446
447    #[test]
448    fn it_parses_ope_index() {
449        let input = json!({
450            "v": 1,
451            "tables": {
452                "users": {
453                    "age": {
454                        "plaintext_type": "int",
455                        "indexes": { "ope": {} }
456                    }
457                }
458            }
459        });
460
461        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
462        let col = config.tables.0.get("users").unwrap().0.get("age").unwrap();
463        assert!(col.indexes.ope.is_some());
464    }
465
466    #[test]
467    fn it_round_trips_ope_index_through_json() {
468        let input = json!({
469            "v": 1,
470            "tables": {
471                "users": {
472                    "age": {
473                        "plaintext_type": "int",
474                        "indexes": { "ope": {} }
475                    }
476                }
477            }
478        });
479
480        let config: CanonicalEncryptionConfig = serde_json::from_value(input.clone()).unwrap();
481        let serialized = serde_json::to_value(&config).unwrap();
482        let reparsed: CanonicalEncryptionConfig = serde_json::from_value(serialized).unwrap();
483
484        let col = reparsed
485            .tables
486            .0
487            .get("users")
488            .unwrap()
489            .0
490            .get("age")
491            .unwrap();
492        assert!(col.indexes.ope.is_some());
493        assert!(col.indexes.ore.is_none());
494    }
495
496    #[test]
497    fn it_parses_match_index_with_defaults() {
498        let input = json!({
499            "v": 1,
500            "tables": {
501                "users": {
502                    "name": {
503                        "plaintext_type": "text",
504                        "indexes": { "match": {} }
505                    }
506                }
507            }
508        });
509
510        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
511        let col = config.tables.0.get("users").unwrap().0.get("name").unwrap();
512        let match_opts = col.indexes.match_index.as_ref().unwrap();
513        assert_eq!(match_opts.tokenizer, Tokenizer::Standard);
514        assert_eq!(match_opts.k, 6);
515        assert_eq!(match_opts.m, 2048);
516        assert!(!match_opts.include_original);
517        assert!(match_opts.token_filters.is_empty());
518    }
519
520    #[test]
521    fn it_parses_unique_index() {
522        let input = json!({
523            "v": 1,
524            "tables": {
525                "users": {
526                    "email": {
527                        "plaintext_type": "text",
528                        "indexes": {
529                            "unique": {
530                                "token_filters": [{ "kind": "downcase" }]
531                            }
532                        }
533                    }
534                }
535            }
536        });
537
538        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
539        let col = config
540            .tables
541            .0
542            .get("users")
543            .unwrap()
544            .0
545            .get("email")
546            .unwrap();
547        let unique_opts = col.indexes.unique.as_ref().unwrap();
548        assert_eq!(unique_opts.token_filters.len(), 1);
549    }
550
551    #[test]
552    fn it_parses_ste_vec_index() {
553        let input = json!({
554            "v": 1,
555            "tables": {
556                "events": {
557                    "data": {
558                        "plaintext_type": "json",
559                        "indexes": {
560                            "ste_vec": {
561                                "prefix": "event-data",
562                                "array_index_mode": "all"
563                            }
564                        }
565                    }
566                }
567            }
568        });
569
570        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
571        let col = config
572            .tables
573            .0
574            .get("events")
575            .unwrap()
576            .0
577            .get("data")
578            .unwrap();
579        let ste_vec_opts = col.indexes.ste_vec.as_ref().unwrap();
580        assert_eq!(ste_vec_opts.prefix, "event-data");
581    }
582
583    #[test]
584    fn it_defaults_ste_vec_mode_to_standard() {
585        let input = json!({
586            "v": 1,
587            "tables": {
588                "events": {
589                    "data": {
590                        "plaintext_type": "json",
591                        "indexes": {
592                            "ste_vec": { "prefix": "event-data" }
593                        }
594                    }
595                }
596            }
597        });
598
599        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
600        let col = config
601            .tables
602            .0
603            .get("events")
604            .unwrap()
605            .0
606            .get("data")
607            .unwrap();
608        let ste_vec_opts = col.indexes.ste_vec.as_ref().unwrap();
609        assert_eq!(ste_vec_opts.mode, SteVecMode::Standard);
610    }
611
612    #[test]
613    fn it_parses_ste_vec_mode_standard() {
614        let input = json!({
615            "v": 1,
616            "tables": {
617                "events": {
618                    "data": {
619                        "plaintext_type": "json",
620                        "indexes": {
621                            "ste_vec": { "prefix": "event-data", "mode": "standard" }
622                        }
623                    }
624                }
625            }
626        });
627
628        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
629        let col = config
630            .tables
631            .0
632            .get("events")
633            .unwrap()
634            .0
635            .get("data")
636            .unwrap();
637        let ste_vec_opts = col.indexes.ste_vec.as_ref().unwrap();
638        assert_eq!(ste_vec_opts.mode, SteVecMode::Standard);
639    }
640
641    #[test]
642    fn it_propagates_ste_vec_mode_into_config_map() {
643        let input = json!({
644            "v": 1,
645            "tables": {
646                "events": {
647                    "data": {
648                        "plaintext_type": "json",
649                        "indexes": {
650                            "ste_vec": { "prefix": "event-data", "mode": "standard" }
651                        }
652                    }
653                }
654            }
655        });
656
657        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
658        let map = config.into_config_map().unwrap();
659        let col = map.get(&Identifier::new("events", "data")).unwrap();
660
661        assert!(matches!(
662            col.indexes[0].index_type,
663            IndexType::SteVec {
664                mode: SteVecMode::Standard,
665                ..
666            }
667        ));
668    }
669
670    #[test]
671    fn it_parses_empty_indexes() {
672        let input = json!({
673            "v": 1,
674            "tables": {
675                "users": {
676                    "email": {
677                        "plaintext_type": "text",
678                        "indexes": {}
679                    }
680                }
681            }
682        });
683
684        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
685        let col = config
686            .tables
687            .0
688            .get("users")
689            .unwrap()
690            .0
691            .get("email")
692            .unwrap();
693        assert!(col.indexes.ore.is_none());
694        assert!(col.indexes.unique.is_none());
695        assert!(col.indexes.match_index.is_none());
696        assert!(col.indexes.ste_vec.is_none());
697    }
698
699    #[test]
700    fn it_converts_to_config_map() {
701        let input = json!({
702            "v": 1,
703            "tables": {
704                "users": {
705                    "email": {
706                        "plaintext_type": "text",
707                        "indexes": {
708                            "ore": {},
709                            "unique": { "token_filters": [{ "kind": "downcase" }] }
710                        }
711                    }
712                }
713            }
714        });
715
716        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
717        let map = config.into_config_map().unwrap();
718
719        let id = Identifier::new("users", "email");
720        let col = map.get(&id).unwrap();
721        assert_eq!(col.cast_type, ColumnType::Text);
722        assert_eq!(col.indexes.len(), 2);
723    }
724
725    #[test]
726    fn it_defaults_empty_column_to_text() {
727        let input = json!({
728            "v": 1,
729            "tables": {
730                "users": {
731                    "email": {}
732                }
733            }
734        });
735
736        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
737        let map = config.into_config_map().unwrap();
738
739        let id = Identifier::new("users", "email");
740        let col = map.get(&id).unwrap();
741        assert_eq!(col.cast_type, ColumnType::Text);
742        assert!(col.indexes.is_empty());
743    }
744
745    #[test]
746    fn it_rejects_ste_vec_on_non_json_column() {
747        let input = json!({
748            "v": 1,
749            "tables": {
750                "users": {
751                    "email": {
752                        "plaintext_type": "text",
753                        "indexes": {
754                            "ste_vec": { "prefix": "test" }
755                        }
756                    }
757                }
758            }
759        });
760
761        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
762        let result = config.into_config_map();
763        assert!(result.is_err());
764        let err = result.unwrap_err().to_string();
765        assert!(
766            err.contains("ste_vec"),
767            "Error should mention ste_vec: {err}"
768        );
769        assert!(err.contains("json"), "Error should mention json: {err}");
770    }
771
772    #[test]
773    fn it_allows_ste_vec_on_json_column() {
774        let input = json!({
775            "v": 1,
776            "tables": {
777                "events": {
778                    "data": {
779                        "plaintext_type": "json",
780                        "indexes": {
781                            "ste_vec": { "prefix": "event-data" }
782                        }
783                    }
784                }
785            }
786        });
787
788        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
789        let map = config.into_config_map().unwrap();
790
791        let id = Identifier::new("events", "data");
792        let col = map.get(&id).unwrap();
793        assert_eq!(col.cast_type, ColumnType::Json);
794    }
795
796    #[test]
797    fn it_parses_from_json_string() {
798        let json_str =
799            r#"{"v":1,"tables":{"t":{"c":{"plaintext_type":"int","indexes":{"ore":{}}}}}}"#;
800        let config: CanonicalEncryptionConfig = json_str.parse().unwrap();
801        let map = config.into_config_map().unwrap();
802        let col = map.get(&Identifier::new("t", "c")).unwrap();
803        assert_eq!(col.cast_type, ColumnType::Int);
804    }
805
806    #[test]
807    fn it_handles_backwards_compat_cast_as_jsonb() {
808        let input = json!({
809            "v": 1,
810            "tables": {
811                "events": {
812                    "data": {
813                        "cast_as": "jsonb",
814                        "indexes": {
815                            "ste_vec": { "prefix": "test" }
816                        }
817                    }
818                }
819            }
820        });
821
822        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
823        let map = config.into_config_map().unwrap();
824        let id = Identifier::new("events", "data");
825        let col = map.get(&id).unwrap();
826        assert_eq!(col.cast_type, ColumnType::Json);
827    }
828
829    #[test]
830    fn it_produces_correct_index_types_for_multi_index_column() {
831        let input = json!({
832            "v": 1,
833            "tables": {
834                "encrypted": {
835                    "encrypted_text": {
836                        "cast_as": "text",
837                        "indexes": {
838                            "unique": {},
839                            "match": {},
840                            "ore": {}
841                        }
842                    }
843                }
844            }
845        });
846
847        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
848        let map = config.into_config_map().unwrap();
849
850        let id = Identifier::new("encrypted", "encrypted_text");
851        let col = map.get(&id).unwrap();
852
853        assert_eq!(col.cast_type, ColumnType::Text);
854        assert_eq!(col.name, "encrypted_text");
855        assert_eq!(col.indexes.len(), 3);
856
857        let index_types: Vec<_> = col.indexes.iter().map(|i| &i.index_type).collect();
858        assert!(index_types.contains(&&IndexType::Ore));
859        assert!(index_types
860            .iter()
861            .any(|t| matches!(t, IndexType::Unique { .. })));
862        assert!(index_types
863            .iter()
864            .any(|t| matches!(t, IndexType::Match { .. })));
865    }
866
867    #[test]
868    fn it_maps_all_cast_as_values_to_correct_column_types() {
869        let cases = vec![
870            ("text", ColumnType::Text),
871            ("int", ColumnType::Int),
872            ("small_int", ColumnType::SmallInt),
873            ("big_int", ColumnType::BigInt),
874            ("boolean", ColumnType::Boolean),
875            ("date", ColumnType::Date),
876            ("float", ColumnType::Float),
877            ("decimal", ColumnType::Decimal),
878            ("timestamp", ColumnType::Timestamp),
879            // Legacy aliases
880            ("double", ColumnType::Float),
881            ("real", ColumnType::Float),
882            ("jsonb", ColumnType::Json),
883            ("json", ColumnType::Json),
884        ];
885
886        for (cast_as, expected_type) in cases {
887            let input = json!({
888                "v": 1,
889                "tables": {
890                    "t": {
891                        "c": { "cast_as": cast_as }
892                    }
893                }
894            });
895
896            let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
897            let map = config.into_config_map().unwrap();
898            let col = map.get(&Identifier::new("t", "c")).unwrap();
899            assert_eq!(
900                col.cast_type, expected_type,
901                "Failed for cast_as: {cast_as}"
902            );
903        }
904    }
905
906    #[test]
907    fn it_preserves_match_index_defaults_in_config_map() {
908        let input = json!({
909            "v": 1,
910            "tables": {
911                "t": {
912                    "c": {
913                        "cast_as": "text",
914                        "indexes": { "match": {} }
915                    }
916                }
917            }
918        });
919
920        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
921        let map = config.into_config_map().unwrap();
922        let col = map.get(&Identifier::new("t", "c")).unwrap();
923
924        assert_eq!(col.indexes.len(), 1);
925        assert_eq!(
926            col.indexes[0].index_type,
927            IndexType::Match {
928                tokenizer: Tokenizer::Standard,
929                token_filters: vec![],
930                k: 6,
931                m: 2048,
932                include_original: false,
933            }
934        );
935    }
936
937    /// This fixture represents the JSON stored in `eql_v2_configuration.data`
938    /// after running the proxy integration test schema setup.
939    /// If this test breaks, existing production configs will fail to load.
940    #[test]
941    fn it_parses_real_eql_integration_test_config() {
942        let input = json!({
943            "v": 1,
944            "tables": {
945                "encrypted": {
946                    "encrypted_text": {
947                        "cast_as": "text",
948                        "indexes": {
949                            "unique": {},
950                            "match": {},
951                            "ore": {}
952                        }
953                    },
954                    "encrypted_bool": {
955                        "cast_as": "boolean",
956                        "indexes": {
957                            "unique": {},
958                            "ore": {}
959                        }
960                    },
961                    "encrypted_int2": {
962                        "cast_as": "small_int",
963                        "indexes": {
964                            "unique": {},
965                            "ore": {}
966                        }
967                    },
968                    "encrypted_int4": {
969                        "cast_as": "int",
970                        "indexes": {
971                            "unique": {},
972                            "ore": {}
973                        }
974                    },
975                    "encrypted_int8": {
976                        "cast_as": "big_int",
977                        "indexes": {
978                            "unique": {},
979                            "ore": {}
980                        }
981                    },
982                    "encrypted_float8": {
983                        "cast_as": "double",
984                        "indexes": {
985                            "unique": {},
986                            "ore": {}
987                        }
988                    },
989                    "encrypted_date": {
990                        "cast_as": "date",
991                        "indexes": {
992                            "unique": {},
993                            "ore": {}
994                        }
995                    },
996                    "encrypted_jsonb": {
997                        "cast_as": "jsonb",
998                        "indexes": {
999                            "ste_vec": {
1000                                "prefix": "encrypted/encrypted_jsonb"
1001                            }
1002                        }
1003                    },
1004                    "encrypted_jsonb_filtered": {
1005                        "cast_as": "jsonb",
1006                        "indexes": {
1007                            "ste_vec": {
1008                                "prefix": "encrypted/encrypted_jsonb_filtered",
1009                                "term_filters": [{ "kind": "downcase" }]
1010                            }
1011                        }
1012                    }
1013                }
1014            }
1015        });
1016
1017        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
1018        let map = config.into_config_map().unwrap();
1019
1020        // Verify all 9 columns are present
1021        assert_eq!(map.len(), 9);
1022
1023        // Spot check key columns
1024        let text_col = map
1025            .get(&Identifier::new("encrypted", "encrypted_text"))
1026            .unwrap();
1027        assert_eq!(text_col.cast_type, ColumnType::Text);
1028        assert_eq!(text_col.indexes.len(), 3);
1029
1030        let bool_col = map
1031            .get(&Identifier::new("encrypted", "encrypted_bool"))
1032            .unwrap();
1033        assert_eq!(bool_col.cast_type, ColumnType::Boolean);
1034        assert_eq!(bool_col.indexes.len(), 2);
1035
1036        let float_col = map
1037            .get(&Identifier::new("encrypted", "encrypted_float8"))
1038            .unwrap();
1039        assert_eq!(float_col.cast_type, ColumnType::Float); // "double" maps to Float
1040
1041        let jsonb_col = map
1042            .get(&Identifier::new("encrypted", "encrypted_jsonb"))
1043            .unwrap();
1044        assert_eq!(jsonb_col.cast_type, ColumnType::Json); // "jsonb" maps to Json
1045        assert_eq!(jsonb_col.indexes.len(), 1);
1046        assert!(matches!(
1047            jsonb_col.indexes[0].index_type,
1048            IndexType::SteVec { ref prefix, .. } if prefix == "encrypted/encrypted_jsonb"
1049        ));
1050
1051        let filtered_col = map
1052            .get(&Identifier::new("encrypted", "encrypted_jsonb_filtered"))
1053            .unwrap();
1054        assert!(matches!(
1055            &filtered_col.indexes[0].index_type,
1056            IndexType::SteVec { term_filters, .. } if term_filters.len() == 1
1057        ));
1058    }
1059
1060    #[test]
1061    fn it_rejects_unsupported_version() {
1062        let input = json!({
1063            "v": 2,
1064            "tables": {
1065                "users": {
1066                    "email": {}
1067                }
1068            }
1069        });
1070
1071        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
1072        let result = config.into_config_map();
1073        assert!(result.is_err());
1074        let err = result.unwrap_err().to_string();
1075        assert!(err.contains("unsupported config version"), "Error: {err}");
1076    }
1077
1078    #[test]
1079    fn it_rejects_match_index_on_non_text_column() {
1080        let input = json!({
1081            "v": 1,
1082            "tables": {
1083                "users": {
1084                    "age": {
1085                        "plaintext_type": "int",
1086                        "indexes": { "match": {} }
1087                    }
1088                }
1089            }
1090        });
1091
1092        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
1093        let result = config.into_config_map();
1094        assert!(result.is_err());
1095        let err = result.unwrap_err().to_string();
1096        assert!(err.contains("match"), "Error should mention match: {err}");
1097        assert!(err.contains("text"), "Error should mention text: {err}");
1098    }
1099
1100    #[test]
1101    fn it_displays_identifier() {
1102        let id = Identifier::new("users", "email");
1103        assert_eq!(id.to_string(), "users.email");
1104    }
1105
1106    #[test]
1107    fn it_silently_ignores_dropped_legacy_fields() {
1108        let input = json!({
1109            "v": 1,
1110            "tables": {
1111                "users": {
1112                    "email": {
1113                        "cast_as": "text",
1114                        "mode": "encrypted",
1115                        "in_place": true,
1116                        "indexes": {
1117                            "unique": {
1118                                "token_filters": [{ "kind": "downcase" }],
1119                                "mode": "encrypted",
1120                                "in_place": false
1121                            }
1122                        }
1123                    }
1124                }
1125            }
1126        });
1127
1128        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
1129        let map = config.into_config_map().unwrap();
1130        let col = map.get(&Identifier::new("users", "email")).unwrap();
1131        assert_eq!(col.cast_type, ColumnType::Text);
1132        assert_eq!(col.indexes.len(), 1);
1133    }
1134}