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