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_compat() {
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::Compat);
610    }
611
612    #[test]
613    fn it_serializes_defaulted_ste_vec_mode_as_compat() {
614        // A config parsed without an explicit mode re-serializes with the
615        // defaulted mode pinned on the wire as "compat".
616        let input = json!({
617            "v": 1,
618            "tables": {
619                "events": {
620                    "data": {
621                        "plaintext_type": "json",
622                        "indexes": {
623                            "ste_vec": { "prefix": "event-data" }
624                        }
625                    }
626                }
627            }
628        });
629
630        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
631        let col = config
632            .tables
633            .0
634            .get("events")
635            .unwrap()
636            .0
637            .get("data")
638            .unwrap();
639        let ste_vec_opts = col.indexes.ste_vec.as_ref().unwrap();
640        let serialized = serde_json::to_value(ste_vec_opts).unwrap();
641        assert_eq!(serialized["mode"], json!("compat"));
642    }
643
644    #[test]
645    fn it_parses_ste_vec_mode_standard() {
646        let input = json!({
647            "v": 1,
648            "tables": {
649                "events": {
650                    "data": {
651                        "plaintext_type": "json",
652                        "indexes": {
653                            "ste_vec": { "prefix": "event-data", "mode": "standard" }
654                        }
655                    }
656                }
657            }
658        });
659
660        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
661        let col = config
662            .tables
663            .0
664            .get("events")
665            .unwrap()
666            .0
667            .get("data")
668            .unwrap();
669        let ste_vec_opts = col.indexes.ste_vec.as_ref().unwrap();
670        assert_eq!(ste_vec_opts.mode, SteVecMode::Standard);
671    }
672
673    #[test]
674    fn it_propagates_ste_vec_mode_into_config_map() {
675        let input = json!({
676            "v": 1,
677            "tables": {
678                "events": {
679                    "data": {
680                        "plaintext_type": "json",
681                        "indexes": {
682                            "ste_vec": { "prefix": "event-data", "mode": "standard" }
683                        }
684                    }
685                }
686            }
687        });
688
689        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
690        let map = config.into_config_map().unwrap();
691        let col = map.get(&Identifier::new("events", "data")).unwrap();
692
693        assert!(matches!(
694            col.indexes[0].index_type,
695            IndexType::SteVec {
696                mode: SteVecMode::Standard,
697                ..
698            }
699        ));
700    }
701
702    #[test]
703    fn it_parses_empty_indexes() {
704        let input = json!({
705            "v": 1,
706            "tables": {
707                "users": {
708                    "email": {
709                        "plaintext_type": "text",
710                        "indexes": {}
711                    }
712                }
713            }
714        });
715
716        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
717        let col = config
718            .tables
719            .0
720            .get("users")
721            .unwrap()
722            .0
723            .get("email")
724            .unwrap();
725        assert!(col.indexes.ore.is_none());
726        assert!(col.indexes.unique.is_none());
727        assert!(col.indexes.match_index.is_none());
728        assert!(col.indexes.ste_vec.is_none());
729    }
730
731    #[test]
732    fn it_converts_to_config_map() {
733        let input = json!({
734            "v": 1,
735            "tables": {
736                "users": {
737                    "email": {
738                        "plaintext_type": "text",
739                        "indexes": {
740                            "ore": {},
741                            "unique": { "token_filters": [{ "kind": "downcase" }] }
742                        }
743                    }
744                }
745            }
746        });
747
748        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
749        let map = config.into_config_map().unwrap();
750
751        let id = Identifier::new("users", "email");
752        let col = map.get(&id).unwrap();
753        assert_eq!(col.cast_type, ColumnType::Text);
754        assert_eq!(col.indexes.len(), 2);
755    }
756
757    #[test]
758    fn it_defaults_empty_column_to_text() {
759        let input = json!({
760            "v": 1,
761            "tables": {
762                "users": {
763                    "email": {}
764                }
765            }
766        });
767
768        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
769        let map = config.into_config_map().unwrap();
770
771        let id = Identifier::new("users", "email");
772        let col = map.get(&id).unwrap();
773        assert_eq!(col.cast_type, ColumnType::Text);
774        assert!(col.indexes.is_empty());
775    }
776
777    #[test]
778    fn it_rejects_ste_vec_on_non_json_column() {
779        let input = json!({
780            "v": 1,
781            "tables": {
782                "users": {
783                    "email": {
784                        "plaintext_type": "text",
785                        "indexes": {
786                            "ste_vec": { "prefix": "test" }
787                        }
788                    }
789                }
790            }
791        });
792
793        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
794        let result = config.into_config_map();
795        assert!(result.is_err());
796        let err = result.unwrap_err().to_string();
797        assert!(
798            err.contains("ste_vec"),
799            "Error should mention ste_vec: {err}"
800        );
801        assert!(err.contains("json"), "Error should mention json: {err}");
802    }
803
804    #[test]
805    fn it_allows_ste_vec_on_json_column() {
806        let input = json!({
807            "v": 1,
808            "tables": {
809                "events": {
810                    "data": {
811                        "plaintext_type": "json",
812                        "indexes": {
813                            "ste_vec": { "prefix": "event-data" }
814                        }
815                    }
816                }
817            }
818        });
819
820        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
821        let map = config.into_config_map().unwrap();
822
823        let id = Identifier::new("events", "data");
824        let col = map.get(&id).unwrap();
825        assert_eq!(col.cast_type, ColumnType::Json);
826    }
827
828    #[test]
829    fn it_parses_from_json_string() {
830        let json_str =
831            r#"{"v":1,"tables":{"t":{"c":{"plaintext_type":"int","indexes":{"ore":{}}}}}}"#;
832        let config: CanonicalEncryptionConfig = json_str.parse().unwrap();
833        let map = config.into_config_map().unwrap();
834        let col = map.get(&Identifier::new("t", "c")).unwrap();
835        assert_eq!(col.cast_type, ColumnType::Int);
836    }
837
838    #[test]
839    fn it_handles_backwards_compat_cast_as_jsonb() {
840        let input = json!({
841            "v": 1,
842            "tables": {
843                "events": {
844                    "data": {
845                        "cast_as": "jsonb",
846                        "indexes": {
847                            "ste_vec": { "prefix": "test" }
848                        }
849                    }
850                }
851            }
852        });
853
854        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
855        let map = config.into_config_map().unwrap();
856        let id = Identifier::new("events", "data");
857        let col = map.get(&id).unwrap();
858        assert_eq!(col.cast_type, ColumnType::Json);
859    }
860
861    #[test]
862    fn it_produces_correct_index_types_for_multi_index_column() {
863        let input = json!({
864            "v": 1,
865            "tables": {
866                "encrypted": {
867                    "encrypted_text": {
868                        "cast_as": "text",
869                        "indexes": {
870                            "unique": {},
871                            "match": {},
872                            "ore": {}
873                        }
874                    }
875                }
876            }
877        });
878
879        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
880        let map = config.into_config_map().unwrap();
881
882        let id = Identifier::new("encrypted", "encrypted_text");
883        let col = map.get(&id).unwrap();
884
885        assert_eq!(col.cast_type, ColumnType::Text);
886        assert_eq!(col.name, "encrypted_text");
887        assert_eq!(col.indexes.len(), 3);
888
889        let index_types: Vec<_> = col.indexes.iter().map(|i| &i.index_type).collect();
890        assert!(index_types.contains(&&IndexType::Ore));
891        assert!(index_types
892            .iter()
893            .any(|t| matches!(t, IndexType::Unique { .. })));
894        assert!(index_types
895            .iter()
896            .any(|t| matches!(t, IndexType::Match { .. })));
897    }
898
899    #[test]
900    fn it_maps_all_cast_as_values_to_correct_column_types() {
901        let cases = vec![
902            ("text", ColumnType::Text),
903            ("int", ColumnType::Int),
904            ("small_int", ColumnType::SmallInt),
905            ("big_int", ColumnType::BigInt),
906            ("boolean", ColumnType::Boolean),
907            ("date", ColumnType::Date),
908            ("float", ColumnType::Float),
909            ("decimal", ColumnType::Decimal),
910            ("timestamp", ColumnType::Timestamp),
911            // Legacy aliases
912            ("double", ColumnType::Float),
913            ("real", ColumnType::Float),
914            ("jsonb", ColumnType::Json),
915            ("json", ColumnType::Json),
916        ];
917
918        for (cast_as, expected_type) in cases {
919            let input = json!({
920                "v": 1,
921                "tables": {
922                    "t": {
923                        "c": { "cast_as": cast_as }
924                    }
925                }
926            });
927
928            let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
929            let map = config.into_config_map().unwrap();
930            let col = map.get(&Identifier::new("t", "c")).unwrap();
931            assert_eq!(
932                col.cast_type, expected_type,
933                "Failed for cast_as: {cast_as}"
934            );
935        }
936    }
937
938    #[test]
939    fn it_preserves_match_index_defaults_in_config_map() {
940        let input = json!({
941            "v": 1,
942            "tables": {
943                "t": {
944                    "c": {
945                        "cast_as": "text",
946                        "indexes": { "match": {} }
947                    }
948                }
949            }
950        });
951
952        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
953        let map = config.into_config_map().unwrap();
954        let col = map.get(&Identifier::new("t", "c")).unwrap();
955
956        assert_eq!(col.indexes.len(), 1);
957        assert_eq!(
958            col.indexes[0].index_type,
959            IndexType::Match {
960                tokenizer: Tokenizer::Standard,
961                token_filters: vec![],
962                k: 6,
963                m: 2048,
964                include_original: false,
965            }
966        );
967    }
968
969    /// This fixture represents the JSON stored in `eql_v2_configuration.data`
970    /// after running the proxy integration test schema setup.
971    /// If this test breaks, existing production configs will fail to load.
972    #[test]
973    fn it_parses_real_eql_integration_test_config() {
974        let input = json!({
975            "v": 1,
976            "tables": {
977                "encrypted": {
978                    "encrypted_text": {
979                        "cast_as": "text",
980                        "indexes": {
981                            "unique": {},
982                            "match": {},
983                            "ore": {}
984                        }
985                    },
986                    "encrypted_bool": {
987                        "cast_as": "boolean",
988                        "indexes": {
989                            "unique": {},
990                            "ore": {}
991                        }
992                    },
993                    "encrypted_int2": {
994                        "cast_as": "small_int",
995                        "indexes": {
996                            "unique": {},
997                            "ore": {}
998                        }
999                    },
1000                    "encrypted_int4": {
1001                        "cast_as": "int",
1002                        "indexes": {
1003                            "unique": {},
1004                            "ore": {}
1005                        }
1006                    },
1007                    "encrypted_int8": {
1008                        "cast_as": "big_int",
1009                        "indexes": {
1010                            "unique": {},
1011                            "ore": {}
1012                        }
1013                    },
1014                    "encrypted_float8": {
1015                        "cast_as": "double",
1016                        "indexes": {
1017                            "unique": {},
1018                            "ore": {}
1019                        }
1020                    },
1021                    "encrypted_date": {
1022                        "cast_as": "date",
1023                        "indexes": {
1024                            "unique": {},
1025                            "ore": {}
1026                        }
1027                    },
1028                    "encrypted_jsonb": {
1029                        "cast_as": "jsonb",
1030                        "indexes": {
1031                            "ste_vec": {
1032                                "prefix": "encrypted/encrypted_jsonb"
1033                            }
1034                        }
1035                    },
1036                    "encrypted_jsonb_filtered": {
1037                        "cast_as": "jsonb",
1038                        "indexes": {
1039                            "ste_vec": {
1040                                "prefix": "encrypted/encrypted_jsonb_filtered",
1041                                "term_filters": [{ "kind": "downcase" }]
1042                            }
1043                        }
1044                    }
1045                }
1046            }
1047        });
1048
1049        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
1050        let map = config.into_config_map().unwrap();
1051
1052        // Verify all 9 columns are present
1053        assert_eq!(map.len(), 9);
1054
1055        // Spot check key columns
1056        let text_col = map
1057            .get(&Identifier::new("encrypted", "encrypted_text"))
1058            .unwrap();
1059        assert_eq!(text_col.cast_type, ColumnType::Text);
1060        assert_eq!(text_col.indexes.len(), 3);
1061
1062        let bool_col = map
1063            .get(&Identifier::new("encrypted", "encrypted_bool"))
1064            .unwrap();
1065        assert_eq!(bool_col.cast_type, ColumnType::Boolean);
1066        assert_eq!(bool_col.indexes.len(), 2);
1067
1068        let float_col = map
1069            .get(&Identifier::new("encrypted", "encrypted_float8"))
1070            .unwrap();
1071        assert_eq!(float_col.cast_type, ColumnType::Float); // "double" maps to Float
1072
1073        let jsonb_col = map
1074            .get(&Identifier::new("encrypted", "encrypted_jsonb"))
1075            .unwrap();
1076        assert_eq!(jsonb_col.cast_type, ColumnType::Json); // "jsonb" maps to Json
1077        assert_eq!(jsonb_col.indexes.len(), 1);
1078        assert!(matches!(
1079            jsonb_col.indexes[0].index_type,
1080            IndexType::SteVec { ref prefix, .. } if prefix == "encrypted/encrypted_jsonb"
1081        ));
1082
1083        let filtered_col = map
1084            .get(&Identifier::new("encrypted", "encrypted_jsonb_filtered"))
1085            .unwrap();
1086        assert!(matches!(
1087            &filtered_col.indexes[0].index_type,
1088            IndexType::SteVec { term_filters, .. } if term_filters.len() == 1
1089        ));
1090    }
1091
1092    #[test]
1093    fn it_rejects_unsupported_version() {
1094        let input = json!({
1095            "v": 2,
1096            "tables": {
1097                "users": {
1098                    "email": {}
1099                }
1100            }
1101        });
1102
1103        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
1104        let result = config.into_config_map();
1105        assert!(result.is_err());
1106        let err = result.unwrap_err().to_string();
1107        assert!(err.contains("unsupported config version"), "Error: {err}");
1108    }
1109
1110    #[test]
1111    fn it_rejects_match_index_on_non_text_column() {
1112        let input = json!({
1113            "v": 1,
1114            "tables": {
1115                "users": {
1116                    "age": {
1117                        "plaintext_type": "int",
1118                        "indexes": { "match": {} }
1119                    }
1120                }
1121            }
1122        });
1123
1124        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
1125        let result = config.into_config_map();
1126        assert!(result.is_err());
1127        let err = result.unwrap_err().to_string();
1128        assert!(err.contains("match"), "Error should mention match: {err}");
1129        assert!(err.contains("text"), "Error should mention text: {err}");
1130    }
1131
1132    #[test]
1133    fn it_displays_identifier() {
1134        let id = Identifier::new("users", "email");
1135        assert_eq!(id.to_string(), "users.email");
1136    }
1137
1138    #[test]
1139    fn it_silently_ignores_dropped_legacy_fields() {
1140        let input = json!({
1141            "v": 1,
1142            "tables": {
1143                "users": {
1144                    "email": {
1145                        "cast_as": "text",
1146                        "mode": "encrypted",
1147                        "in_place": true,
1148                        "indexes": {
1149                            "unique": {
1150                                "token_filters": [{ "kind": "downcase" }],
1151                                "mode": "encrypted",
1152                                "in_place": false
1153                            }
1154                        }
1155                    }
1156                }
1157            }
1158        });
1159
1160        let config: CanonicalEncryptionConfig = serde_json::from_value(input).unwrap();
1161        let map = config.into_config_map().unwrap();
1162        let col = map.get(&Identifier::new("users", "email")).unwrap();
1163        assert_eq!(col.cast_type, ColumnType::Text);
1164        assert_eq!(col.indexes.len(), 1);
1165    }
1166}