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, 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}
182
183fn default_array_index_mode() -> ArrayIndexMode {
184    ArrayIndexMode::ALL
185}
186
187impl FromStr for CanonicalEncryptionConfig {
188    type Err = ConfigError;
189
190    fn from_str(s: &str) -> Result<Self, Self::Err> {
191        serde_json::from_str(s).map_err(|e| ConfigError::ParseError(e.to_string()))
192    }
193}
194
195impl CanonicalEncryptionConfig {
196    pub fn into_config_map(self) -> Result<HashMap<Identifier, ColumnConfig>, ConfigError> {
197        if self.version != 1 {
198            return Err(ConfigError::UnsupportedVersion {
199                version: self.version,
200                expected: 1,
201            });
202        }
203
204        let mut map = HashMap::new();
205
206        for (table_name, table) in self.tables.0 {
207            for (column_name, column) in table.0 {
208                let identifier = Identifier::new(&table_name, &column_name);
209                let config = column.into_column_config(&table_name, &column_name)?;
210                map.insert(identifier, config);
211            }
212        }
213
214        Ok(map)
215    }
216}
217
218impl Column {
219    fn into_column_config(
220        self,
221        table_name: &str,
222        column_name: &str,
223    ) -> Result<ColumnConfig, ConfigError> {
224        let column_type: ColumnType = self.plaintext_type.into();
225
226        if self.indexes.ste_vec.is_some() && self.plaintext_type != PlaintextType::Json {
227            return Err(ConfigError::SteVecRequiresJson {
228                table: table_name.to_owned(),
229                column: column_name.to_owned(),
230                found_plaintext_type: self.plaintext_type.to_string(),
231            });
232        }
233
234        if self.indexes.match_index.is_some() && self.plaintext_type != PlaintextType::Text {
235            return Err(ConfigError::MatchRequiresText {
236                table: table_name.to_owned(),
237                column: column_name.to_owned(),
238                found_plaintext_type: self.plaintext_type.to_string(),
239            });
240        }
241
242        let mut config = ColumnConfig::build(column_name).casts_as(column_type);
243
244        if self.indexes.ore.is_some() {
245            config = config.add_index(Index::new_ore());
246        }
247
248        if self.indexes.ope.is_some() {
249            config = config.add_index(Index::new_ope());
250        }
251
252        if let Some(unique_opts) = self.indexes.unique {
253            config = config.add_index(Index::new(IndexType::Unique {
254                token_filters: unique_opts.token_filters,
255            }));
256        }
257
258        if let Some(match_opts) = self.indexes.match_index {
259            config = config.add_index(Index::new(IndexType::Match {
260                tokenizer: match_opts.tokenizer,
261                token_filters: match_opts.token_filters,
262                k: match_opts.k,
263                m: match_opts.m,
264                include_original: match_opts.include_original,
265            }));
266        }
267
268        if let Some(ste_vec_opts) = self.indexes.ste_vec {
269            config = config.add_index(Index::new(IndexType::SteVec {
270                prefix: ste_vec_opts.prefix,
271                term_filters: ste_vec_opts.term_filters,
272                array_index_mode: ste_vec_opts.array_index_mode,
273            }));
274        }
275
276        Ok(config)
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use serde_json::json;
284
285    #[test]
286    fn it_deserializes_all_plaintext_types() {
287        let cases = vec![
288            ("text", PlaintextType::Text),
289            ("int", PlaintextType::Int),
290            ("small_int", PlaintextType::SmallInt),
291            ("big_int", PlaintextType::BigInt),
292            ("float", PlaintextType::Float),
293            ("boolean", PlaintextType::Boolean),
294            ("date", PlaintextType::Date),
295            ("json", PlaintextType::Json),
296            ("decimal", PlaintextType::Decimal),
297            ("timestamp", PlaintextType::Timestamp),
298        ];
299
300        for (input, expected) in cases {
301            let result: PlaintextType = serde_json::from_value(json!(input)).unwrap();
302            assert_eq!(result, expected, "Failed for input: {input}");
303        }
304    }
305
306    #[test]
307    fn it_defaults_to_text() {
308        let pt: PlaintextType = Default::default();
309        assert_eq!(pt, PlaintextType::Text);
310    }
311
312    #[test]
313    fn it_accepts_jsonb_alias() {
314        let result: PlaintextType = serde_json::from_value(json!("jsonb")).unwrap();
315        assert_eq!(result, PlaintextType::Json);
316    }
317
318    #[test]
319    fn it_accepts_real_alias() {
320        let result: PlaintextType = serde_json::from_value(json!("real")).unwrap();
321        assert_eq!(result, PlaintextType::Float);
322    }
323
324    #[test]
325    fn it_accepts_double_alias() {
326        let result: PlaintextType = serde_json::from_value(json!("double")).unwrap();
327        assert_eq!(result, PlaintextType::Float);
328    }
329
330    #[test]
331    fn it_converts_to_column_type() {
332        assert_eq!(ColumnType::from(PlaintextType::Text), ColumnType::Text);
333        assert_eq!(ColumnType::from(PlaintextType::Int), ColumnType::Int);
334        assert_eq!(
335            ColumnType::from(PlaintextType::SmallInt),
336            ColumnType::SmallInt
337        );
338        assert_eq!(ColumnType::from(PlaintextType::BigInt), ColumnType::BigInt);
339        assert_eq!(ColumnType::from(PlaintextType::Float), ColumnType::Float);
340        assert_eq!(
341            ColumnType::from(PlaintextType::Boolean),
342            ColumnType::Boolean
343        );
344        assert_eq!(ColumnType::from(PlaintextType::Date), ColumnType::Date);
345        assert_eq!(ColumnType::from(PlaintextType::Json), ColumnType::Json);
346        assert_eq!(
347            ColumnType::from(PlaintextType::Decimal),
348            ColumnType::Decimal
349        );
350        assert_eq!(
351            ColumnType::from(PlaintextType::Timestamp),
352            ColumnType::Timestamp
353        );
354    }
355
356    #[test]
357    fn it_serializes_to_canonical_names() {
358        assert_eq!(
359            serde_json::to_value(PlaintextType::Text).unwrap(),
360            json!("text")
361        );
362        assert_eq!(
363            serde_json::to_value(PlaintextType::Json).unwrap(),
364            json!("json")
365        );
366        assert_eq!(
367            serde_json::to_value(PlaintextType::BigInt).unwrap(),
368            json!("big_int")
369        );
370    }
371
372    #[test]
373    fn it_parses_minimal_config() {
374        let input = json!({
375            "v": 1,
376            "tables": {
377                "users": {
378                    "email": {}
379                }
380            }
381        });
382
383        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
384        assert_eq!(config.version, 1);
385    }
386
387    #[test]
388    fn it_accepts_cast_as_field_name() {
389        let input = json!({
390            "v": 1,
391            "tables": {
392                "users": {
393                    "email": {
394                        "cast_as": "int"
395                    }
396                }
397            }
398        });
399
400        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
401        let table = config.tables.0.get("users").unwrap();
402        let column = table.0.get("email").unwrap();
403        assert_eq!(column.plaintext_type, PlaintextType::Int);
404    }
405
406    #[test]
407    fn it_accepts_plaintext_type_field_name() {
408        let input = json!({
409            "v": 1,
410            "tables": {
411                "users": {
412                    "email": {
413                        "plaintext_type": "int"
414                    }
415                }
416            }
417        });
418
419        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
420        let table = config.tables.0.get("users").unwrap();
421        let column = table.0.get("email").unwrap();
422        assert_eq!(column.plaintext_type, PlaintextType::Int);
423    }
424
425    #[test]
426    fn it_parses_ore_index() {
427        let input = json!({
428            "v": 1,
429            "tables": {
430                "users": {
431                    "age": {
432                        "plaintext_type": "int",
433                        "indexes": { "ore": {} }
434                    }
435                }
436            }
437        });
438
439        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
440        let col = config.tables.0.get("users").unwrap().0.get("age").unwrap();
441        assert!(col.indexes.ore.is_some());
442    }
443
444    #[test]
445    fn it_parses_ope_index() {
446        let input = json!({
447            "v": 1,
448            "tables": {
449                "users": {
450                    "age": {
451                        "plaintext_type": "int",
452                        "indexes": { "ope": {} }
453                    }
454                }
455            }
456        });
457
458        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
459        let col = config.tables.0.get("users").unwrap().0.get("age").unwrap();
460        assert!(col.indexes.ope.is_some());
461    }
462
463    #[test]
464    fn it_round_trips_ope_index_through_json() {
465        let input = json!({
466            "v": 1,
467            "tables": {
468                "users": {
469                    "age": {
470                        "plaintext_type": "int",
471                        "indexes": { "ope": {} }
472                    }
473                }
474            }
475        });
476
477        let config: CanonicalEncryptionConfig = serde_json::from_value(input.clone()).unwrap();
478        let serialized = serde_json::to_value(&config).unwrap();
479        let reparsed: CanonicalEncryptionConfig = serde_json::from_value(serialized).unwrap();
480
481        let col = reparsed
482            .tables
483            .0
484            .get("users")
485            .unwrap()
486            .0
487            .get("age")
488            .unwrap();
489        assert!(col.indexes.ope.is_some());
490        assert!(col.indexes.ore.is_none());
491    }
492
493    #[test]
494    fn it_parses_match_index_with_defaults() {
495        let input = json!({
496            "v": 1,
497            "tables": {
498                "users": {
499                    "name": {
500                        "plaintext_type": "text",
501                        "indexes": { "match": {} }
502                    }
503                }
504            }
505        });
506
507        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
508        let col = config.tables.0.get("users").unwrap().0.get("name").unwrap();
509        let match_opts = col.indexes.match_index.as_ref().unwrap();
510        assert_eq!(match_opts.tokenizer, Tokenizer::Standard);
511        assert_eq!(match_opts.k, 6);
512        assert_eq!(match_opts.m, 2048);
513        assert!(!match_opts.include_original);
514        assert!(match_opts.token_filters.is_empty());
515    }
516
517    #[test]
518    fn it_parses_unique_index() {
519        let input = json!({
520            "v": 1,
521            "tables": {
522                "users": {
523                    "email": {
524                        "plaintext_type": "text",
525                        "indexes": {
526                            "unique": {
527                                "token_filters": [{ "kind": "downcase" }]
528                            }
529                        }
530                    }
531                }
532            }
533        });
534
535        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
536        let col = config
537            .tables
538            .0
539            .get("users")
540            .unwrap()
541            .0
542            .get("email")
543            .unwrap();
544        let unique_opts = col.indexes.unique.as_ref().unwrap();
545        assert_eq!(unique_opts.token_filters.len(), 1);
546    }
547
548    #[test]
549    fn it_parses_ste_vec_index() {
550        let input = json!({
551            "v": 1,
552            "tables": {
553                "events": {
554                    "data": {
555                        "plaintext_type": "json",
556                        "indexes": {
557                            "ste_vec": {
558                                "prefix": "event-data",
559                                "array_index_mode": "all"
560                            }
561                        }
562                    }
563                }
564            }
565        });
566
567        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
568        let col = config
569            .tables
570            .0
571            .get("events")
572            .unwrap()
573            .0
574            .get("data")
575            .unwrap();
576        let ste_vec_opts = col.indexes.ste_vec.as_ref().unwrap();
577        assert_eq!(ste_vec_opts.prefix, "event-data");
578    }
579
580    #[test]
581    fn it_parses_empty_indexes() {
582        let input = json!({
583            "v": 1,
584            "tables": {
585                "users": {
586                    "email": {
587                        "plaintext_type": "text",
588                        "indexes": {}
589                    }
590                }
591            }
592        });
593
594        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
595        let col = config
596            .tables
597            .0
598            .get("users")
599            .unwrap()
600            .0
601            .get("email")
602            .unwrap();
603        assert!(col.indexes.ore.is_none());
604        assert!(col.indexes.unique.is_none());
605        assert!(col.indexes.match_index.is_none());
606        assert!(col.indexes.ste_vec.is_none());
607    }
608
609    #[test]
610    fn it_converts_to_config_map() {
611        let input = json!({
612            "v": 1,
613            "tables": {
614                "users": {
615                    "email": {
616                        "plaintext_type": "text",
617                        "indexes": {
618                            "ore": {},
619                            "unique": { "token_filters": [{ "kind": "downcase" }] }
620                        }
621                    }
622                }
623            }
624        });
625
626        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
627        let map = config.into_config_map().unwrap();
628
629        let id = Identifier::new("users", "email");
630        let col = map.get(&id).unwrap();
631        assert_eq!(col.cast_type, ColumnType::Text);
632        assert_eq!(col.indexes.len(), 2);
633    }
634
635    #[test]
636    fn it_defaults_empty_column_to_text() {
637        let input = json!({
638            "v": 1,
639            "tables": {
640                "users": {
641                    "email": {}
642                }
643            }
644        });
645
646        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
647        let map = config.into_config_map().unwrap();
648
649        let id = Identifier::new("users", "email");
650        let col = map.get(&id).unwrap();
651        assert_eq!(col.cast_type, ColumnType::Text);
652        assert!(col.indexes.is_empty());
653    }
654
655    #[test]
656    fn it_rejects_ste_vec_on_non_json_column() {
657        let input = json!({
658            "v": 1,
659            "tables": {
660                "users": {
661                    "email": {
662                        "plaintext_type": "text",
663                        "indexes": {
664                            "ste_vec": { "prefix": "test" }
665                        }
666                    }
667                }
668            }
669        });
670
671        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
672        let result = config.into_config_map();
673        assert!(result.is_err());
674        let err = result.unwrap_err().to_string();
675        assert!(
676            err.contains("ste_vec"),
677            "Error should mention ste_vec: {err}"
678        );
679        assert!(err.contains("json"), "Error should mention json: {err}");
680    }
681
682    #[test]
683    fn it_allows_ste_vec_on_json_column() {
684        let input = json!({
685            "v": 1,
686            "tables": {
687                "events": {
688                    "data": {
689                        "plaintext_type": "json",
690                        "indexes": {
691                            "ste_vec": { "prefix": "event-data" }
692                        }
693                    }
694                }
695            }
696        });
697
698        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
699        let map = config.into_config_map().unwrap();
700
701        let id = Identifier::new("events", "data");
702        let col = map.get(&id).unwrap();
703        assert_eq!(col.cast_type, ColumnType::Json);
704    }
705
706    #[test]
707    fn it_parses_from_json_string() {
708        let json_str =
709            r#"{"v":1,"tables":{"t":{"c":{"plaintext_type":"int","indexes":{"ore":{}}}}}}"#;
710        let config: CanonicalEncryptionConfig = json_str.parse().unwrap();
711        let map = config.into_config_map().unwrap();
712        let col = map.get(&Identifier::new("t", "c")).unwrap();
713        assert_eq!(col.cast_type, ColumnType::Int);
714    }
715
716    #[test]
717    fn it_handles_backwards_compat_cast_as_jsonb() {
718        let input = json!({
719            "v": 1,
720            "tables": {
721                "events": {
722                    "data": {
723                        "cast_as": "jsonb",
724                        "indexes": {
725                            "ste_vec": { "prefix": "test" }
726                        }
727                    }
728                }
729            }
730        });
731
732        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
733        let map = config.into_config_map().unwrap();
734        let id = Identifier::new("events", "data");
735        let col = map.get(&id).unwrap();
736        assert_eq!(col.cast_type, ColumnType::Json);
737    }
738
739    #[test]
740    fn it_produces_correct_index_types_for_multi_index_column() {
741        let input = json!({
742            "v": 1,
743            "tables": {
744                "encrypted": {
745                    "encrypted_text": {
746                        "cast_as": "text",
747                        "indexes": {
748                            "unique": {},
749                            "match": {},
750                            "ore": {}
751                        }
752                    }
753                }
754            }
755        });
756
757        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
758        let map = config.into_config_map().unwrap();
759
760        let id = Identifier::new("encrypted", "encrypted_text");
761        let col = map.get(&id).unwrap();
762
763        assert_eq!(col.cast_type, ColumnType::Text);
764        assert_eq!(col.name, "encrypted_text");
765        assert_eq!(col.indexes.len(), 3);
766
767        let index_types: Vec<_> = col.indexes.iter().map(|i| &i.index_type).collect();
768        assert!(index_types.contains(&&IndexType::Ore));
769        assert!(index_types
770            .iter()
771            .any(|t| matches!(t, IndexType::Unique { .. })));
772        assert!(index_types
773            .iter()
774            .any(|t| matches!(t, IndexType::Match { .. })));
775    }
776
777    #[test]
778    fn it_maps_all_cast_as_values_to_correct_column_types() {
779        let cases = vec![
780            ("text", ColumnType::Text),
781            ("int", ColumnType::Int),
782            ("small_int", ColumnType::SmallInt),
783            ("big_int", ColumnType::BigInt),
784            ("boolean", ColumnType::Boolean),
785            ("date", ColumnType::Date),
786            ("float", ColumnType::Float),
787            ("decimal", ColumnType::Decimal),
788            ("timestamp", ColumnType::Timestamp),
789            // Legacy aliases
790            ("double", ColumnType::Float),
791            ("real", ColumnType::Float),
792            ("jsonb", ColumnType::Json),
793            ("json", ColumnType::Json),
794        ];
795
796        for (cast_as, expected_type) in cases {
797            let input = json!({
798                "v": 1,
799                "tables": {
800                    "t": {
801                        "c": { "cast_as": cast_as }
802                    }
803                }
804            });
805
806            let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
807            let map = config.into_config_map().unwrap();
808            let col = map.get(&Identifier::new("t", "c")).unwrap();
809            assert_eq!(
810                col.cast_type, expected_type,
811                "Failed for cast_as: {cast_as}"
812            );
813        }
814    }
815
816    #[test]
817    fn it_preserves_match_index_defaults_in_config_map() {
818        let input = json!({
819            "v": 1,
820            "tables": {
821                "t": {
822                    "c": {
823                        "cast_as": "text",
824                        "indexes": { "match": {} }
825                    }
826                }
827            }
828        });
829
830        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
831        let map = config.into_config_map().unwrap();
832        let col = map.get(&Identifier::new("t", "c")).unwrap();
833
834        assert_eq!(col.indexes.len(), 1);
835        assert_eq!(
836            col.indexes[0].index_type,
837            IndexType::Match {
838                tokenizer: Tokenizer::Standard,
839                token_filters: vec![],
840                k: 6,
841                m: 2048,
842                include_original: false,
843            }
844        );
845    }
846
847    /// This fixture represents the JSON stored in `eql_v2_configuration.data`
848    /// after running the proxy integration test schema setup.
849    /// If this test breaks, existing production configs will fail to load.
850    #[test]
851    fn it_parses_real_eql_integration_test_config() {
852        let input = json!({
853            "v": 1,
854            "tables": {
855                "encrypted": {
856                    "encrypted_text": {
857                        "cast_as": "text",
858                        "indexes": {
859                            "unique": {},
860                            "match": {},
861                            "ore": {}
862                        }
863                    },
864                    "encrypted_bool": {
865                        "cast_as": "boolean",
866                        "indexes": {
867                            "unique": {},
868                            "ore": {}
869                        }
870                    },
871                    "encrypted_int2": {
872                        "cast_as": "small_int",
873                        "indexes": {
874                            "unique": {},
875                            "ore": {}
876                        }
877                    },
878                    "encrypted_int4": {
879                        "cast_as": "int",
880                        "indexes": {
881                            "unique": {},
882                            "ore": {}
883                        }
884                    },
885                    "encrypted_int8": {
886                        "cast_as": "big_int",
887                        "indexes": {
888                            "unique": {},
889                            "ore": {}
890                        }
891                    },
892                    "encrypted_float8": {
893                        "cast_as": "double",
894                        "indexes": {
895                            "unique": {},
896                            "ore": {}
897                        }
898                    },
899                    "encrypted_date": {
900                        "cast_as": "date",
901                        "indexes": {
902                            "unique": {},
903                            "ore": {}
904                        }
905                    },
906                    "encrypted_jsonb": {
907                        "cast_as": "jsonb",
908                        "indexes": {
909                            "ste_vec": {
910                                "prefix": "encrypted/encrypted_jsonb"
911                            }
912                        }
913                    },
914                    "encrypted_jsonb_filtered": {
915                        "cast_as": "jsonb",
916                        "indexes": {
917                            "ste_vec": {
918                                "prefix": "encrypted/encrypted_jsonb_filtered",
919                                "term_filters": [{ "kind": "downcase" }]
920                            }
921                        }
922                    }
923                }
924            }
925        });
926
927        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
928        let map = config.into_config_map().unwrap();
929
930        // Verify all 9 columns are present
931        assert_eq!(map.len(), 9);
932
933        // Spot check key columns
934        let text_col = map
935            .get(&Identifier::new("encrypted", "encrypted_text"))
936            .unwrap();
937        assert_eq!(text_col.cast_type, ColumnType::Text);
938        assert_eq!(text_col.indexes.len(), 3);
939
940        let bool_col = map
941            .get(&Identifier::new("encrypted", "encrypted_bool"))
942            .unwrap();
943        assert_eq!(bool_col.cast_type, ColumnType::Boolean);
944        assert_eq!(bool_col.indexes.len(), 2);
945
946        let float_col = map
947            .get(&Identifier::new("encrypted", "encrypted_float8"))
948            .unwrap();
949        assert_eq!(float_col.cast_type, ColumnType::Float); // "double" maps to Float
950
951        let jsonb_col = map
952            .get(&Identifier::new("encrypted", "encrypted_jsonb"))
953            .unwrap();
954        assert_eq!(jsonb_col.cast_type, ColumnType::Json); // "jsonb" maps to Json
955        assert_eq!(jsonb_col.indexes.len(), 1);
956        assert!(matches!(
957            jsonb_col.indexes[0].index_type,
958            IndexType::SteVec { ref prefix, .. } if prefix == "encrypted/encrypted_jsonb"
959        ));
960
961        let filtered_col = map
962            .get(&Identifier::new("encrypted", "encrypted_jsonb_filtered"))
963            .unwrap();
964        assert!(matches!(
965            &filtered_col.indexes[0].index_type,
966            IndexType::SteVec { term_filters, .. } if term_filters.len() == 1
967        ));
968    }
969
970    #[test]
971    fn it_rejects_unsupported_version() {
972        let input = json!({
973            "v": 2,
974            "tables": {
975                "users": {
976                    "email": {}
977                }
978            }
979        });
980
981        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
982        let result = config.into_config_map();
983        assert!(result.is_err());
984        let err = result.unwrap_err().to_string();
985        assert!(err.contains("unsupported config version"), "Error: {err}");
986    }
987
988    #[test]
989    fn it_rejects_match_index_on_non_text_column() {
990        let input = json!({
991            "v": 1,
992            "tables": {
993                "users": {
994                    "age": {
995                        "plaintext_type": "int",
996                        "indexes": { "match": {} }
997                    }
998                }
999            }
1000        });
1001
1002        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
1003        let result = config.into_config_map();
1004        assert!(result.is_err());
1005        let err = result.unwrap_err().to_string();
1006        assert!(err.contains("match"), "Error should mention match: {err}");
1007        assert!(err.contains("text"), "Error should mention text: {err}");
1008    }
1009
1010    #[test]
1011    fn it_displays_identifier() {
1012        let id = Identifier::new("users", "email");
1013        assert_eq!(id.to_string(), "users.email");
1014    }
1015
1016    #[test]
1017    fn it_silently_ignores_dropped_legacy_fields() {
1018        let input = json!({
1019            "v": 1,
1020            "tables": {
1021                "users": {
1022                    "email": {
1023                        "cast_as": "text",
1024                        "mode": "encrypted",
1025                        "in_place": true,
1026                        "indexes": {
1027                            "unique": {
1028                                "token_filters": [{ "kind": "downcase" }],
1029                                "mode": "encrypted",
1030                                "in_place": false
1031                            }
1032                        }
1033                    }
1034                }
1035            }
1036        });
1037
1038        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
1039        let map = config.into_config_map().unwrap();
1040        let col = map.get(&Identifier::new("users", "email")).unwrap();
1041        assert_eq!(col.cast_type, ColumnType::Text);
1042        assert_eq!(col.indexes.len(), 1);
1043    }
1044}