Skip to main content

cufflink_types/manifest/
table.rs

1use crate::features;
2use serde::{Deserialize, Serialize};
3use std::borrow::Cow;
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct TableDefinition {
7    pub name: String,
8    pub columns: Vec<ColumnDefinition>,
9    #[serde(default, skip_serializing_if = "Vec::is_empty")]
10    pub indexes: Vec<IndexDefinition>,
11    #[serde(default)]
12    pub soft_delete: bool,
13    /// Column name that stores the owner's user ID for row-level security.
14    /// When set, CRUD operations are scoped to the authenticated user's records.
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub owner_field: Option<String>,
17    /// When true, all CRUD operations on this table require authentication.
18    /// Unauthenticated requests receive a 401 Unauthorized response.
19    #[serde(default)]
20    pub auth_required: bool,
21    /// Permission area that controls access to this table's CRUD operations.
22    /// When set, platform checks "{area}:view" for GET, "{area}:create" for POST,
23    /// "{area}:edit" for PUT, "{area}:delete" for DELETE.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub permission_area: Option<String>,
26    /// Self-service ownership: when true (requires `owner_field`), owning a row
27    /// authorizes access to it — the row-level scoping by `owner_field` IS the
28    /// authorization, so the `permission_area` grant is NOT required for these
29    /// (inherently owner-scoped) CRUD operations. Use for user-owned data a user
30    /// manages themselves (their items, addresses, orders). Defaults false, so
31    /// existing tables keep requiring `permission_area`; cross-owner/staff access
32    /// stays gated by RBAC (typically via dedicated handlers, not owner-scoped CRUD).
33    #[serde(default)]
34    pub owner_self_service: bool,
35    /// CRUD lifecycle hooks — WASM handler names called before/after operations.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub hooks: Option<TableHooks>,
38    /// Scoped permissions: the permission area whose grants' scopes
39    /// (`@own`, `@field(column=claim)`, `@all`) select the rows a caller
40    /// reaches. Requires `owner_field` (the `@own` column); `permission_area`
41    /// defaults to it. Without it the table behaves as before scopes existed.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub scoped_by: Option<String>,
44    /// Whether the platform owns the table's DDL. An unmanaged table
45    /// exists already: it is validated against the database, never created,
46    /// altered or dropped.
47    #[serde(
48        default = "crate::serde_util::yes",
49        skip_serializing_if = "crate::serde_util::is_yes"
50    )]
51    pub managed: bool,
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub checks: Vec<CheckConstraint>,
54    /// What `?search=` matches; every text column by pattern when unset.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub search: Option<SearchSpec>,
57    /// Columns whose values audit records replace: a name, or `prefix*`.
58    #[serde(default, skip_serializing_if = "Vec::is_empty")]
59    pub audit_redact: Vec<String>,
60    /// Reads of the table are audited as well as writes.
61    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
62    pub sensitive: bool,
63    #[serde(default, skip_serializing_if = "Pagination::is_offset")]
64    pub pagination: Pagination,
65}
66
67impl Default for TableDefinition {
68    fn default() -> Self {
69        Self {
70            name: String::new(),
71            columns: Vec::new(),
72            indexes: Vec::new(),
73            soft_delete: false,
74            owner_field: None,
75            auth_required: false,
76            permission_area: None,
77            owner_self_service: false,
78            hooks: None,
79            scoped_by: None,
80            managed: true,
81            checks: Vec::new(),
82            search: None,
83            audit_redact: Vec::new(),
84            sensitive: false,
85            pagination: Pagination::default(),
86        }
87    }
88}
89
90/// How a list is paged.
91#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum Pagination {
94    /// By page number.
95    #[default]
96    Offset,
97    /// By a cursor over the sort keys.
98    Keyset,
99}
100
101impl Pagination {
102    pub fn is_offset(&self) -> bool {
103        *self == Pagination::Offset
104    }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct SearchSpec {
109    /// The columns searched.
110    #[serde(default, skip_serializing_if = "Vec::is_empty")]
111    pub columns: Vec<String>,
112    /// The text search configuration.
113    #[serde(
114        default = "SearchSpec::default_config",
115        skip_serializing_if = "SearchSpec::is_default_config"
116    )]
117    pub config: String,
118    /// An existing `tsvector` column of an unmanaged table, searched as
119    /// it is.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub vector_column: Option<String>,
122}
123
124impl SearchSpec {
125    pub const DEFAULT_CONFIG: &'static str = "simple";
126
127    pub fn default_config() -> String {
128        Self::DEFAULT_CONFIG.to_string()
129    }
130
131    pub fn is_default_config(config: &str) -> bool {
132        config == Self::DEFAULT_CONFIG
133    }
134}
135
136impl Default for SearchSpec {
137    fn default() -> Self {
138        Self {
139            columns: Vec::new(),
140            config: Self::default_config(),
141            vector_column: None,
142        }
143    }
144}
145
146#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
147pub struct CheckConstraint {
148    pub name: String,
149    /// The SQL condition every row satisfies.
150    pub expr: String,
151}
152
153#[derive(Debug, Clone, Default, Serialize, Deserialize)]
154pub struct ColumnDefinition {
155    pub name: String,
156    pub column_type: ColumnType,
157    #[serde(default)]
158    pub primary_key: bool,
159    #[serde(default)]
160    pub nullable: bool,
161    #[serde(default)]
162    pub auto_generate: bool,
163    pub default_value: Option<String>,
164    /// Foreign key reference in "table.column" format
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub references: Option<String>,
167    /// Whether to cascade deletes on the FK
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub on_delete: Option<ForeignKeyAction>,
170    /// Whether this column has a unique constraint
171    #[serde(default)]
172    pub unique: bool,
173    /// Validation rules for this column
174    #[serde(default, skip_serializing_if = "Vec::is_empty")]
175    pub validations: Vec<ValidationRule>,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
179#[serde(tag = "rule", rename_all = "snake_case")]
180// Note: ValidationRule contains f64 fields (Min/Max), so Eq cannot be derived
181pub enum ValidationRule {
182    /// Regex pattern match
183    Regex { pattern: String },
184    /// Minimum numeric value
185    Min { value: f64 },
186    /// Maximum numeric value
187    Max { value: f64 },
188    /// Minimum string length
189    MinLength { value: usize },
190    /// Maximum string length
191    MaxLength { value: usize },
192    /// Must be one of the listed values
193    OneOf { values: Vec<String> },
194}
195
196#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
197pub struct IndexDefinition {
198    pub name: String,
199    pub columns: Vec<String>,
200    #[serde(default)]
201    pub unique: bool,
202    /// The SQL condition of a partial index.
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub predicate: Option<String>,
205    #[serde(default, skip_serializing_if = "IndexMethod::is_btree")]
206    pub method: IndexMethod,
207}
208
209impl IndexDefinition {
210    /// Whether the index has a shape only a `column_types_v2` platform
211    /// builds. An index over several columns, unique or not, is not one:
212    /// platforms built them before the feature existed.
213    pub fn is_v2(&self) -> bool {
214        self.predicate.is_some() || !self.method.is_btree()
215    }
216}
217
218#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(rename_all = "snake_case")]
220pub enum IndexMethod {
221    #[default]
222    Btree,
223    Gin,
224    Gist,
225    Hash,
226    Brin,
227}
228
229impl IndexMethod {
230    pub fn is_btree(&self) -> bool {
231        *self == IndexMethod::Btree
232    }
233
234    pub fn as_sql(self) -> &'static str {
235        match self {
236            IndexMethod::Btree => "btree",
237            IndexMethod::Gin => "gin",
238            IndexMethod::Gist => "gist",
239            IndexMethod::Hash => "hash",
240            IndexMethod::Brin => "brin",
241        }
242    }
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
246#[serde(rename_all = "snake_case")]
247pub enum ForeignKeyAction {
248    Cascade,
249    SetNull,
250    Restrict,
251    NoAction,
252}
253
254#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
255#[serde(rename_all = "lowercase")]
256pub enum ColumnType {
257    Uuid,
258    #[default]
259    Text,
260    Integer,
261    BigInteger,
262    Float,
263    Double,
264    Boolean,
265    Timestamp,
266    Date,
267    Jsonb,
268    #[serde(rename = "numeric")]
269    Numeric {
270        #[serde(default, skip_serializing_if = "Option::is_none")]
271        precision: Option<u16>,
272        #[serde(default, skip_serializing_if = "Option::is_none")]
273        scale: Option<u16>,
274    },
275    #[serde(rename = "varchar")]
276    Varchar {
277        length: u32,
278    },
279    #[serde(rename = "timestamp_naive")]
280    TimestampNaive,
281    #[serde(rename = "time")]
282    Time,
283    #[serde(rename = "bytea")]
284    Bytea,
285    #[serde(rename = "text_array")]
286    TextArray,
287    #[serde(rename = "uuid_array")]
288    UuidArray,
289    #[serde(rename = "integer_array")]
290    IntegerArray,
291    #[serde(rename = "biginteger_array")]
292    BigIntegerArray,
293    #[serde(rename = "jsonb_array")]
294    JsonbArray,
295}
296
297impl ColumnType {
298    pub const MAX_NUMERIC_PRECISION: u16 = 1000;
299    pub const MAX_VARCHAR_LENGTH: u32 = 10_485_760;
300
301    pub fn to_sql(&self) -> Cow<'static, str> {
302        match self {
303            ColumnType::Uuid => "UUID".into(),
304            ColumnType::Text => "TEXT".into(),
305            ColumnType::Integer => "INTEGER".into(),
306            ColumnType::BigInteger => "BIGINT".into(),
307            ColumnType::Float => "REAL".into(),
308            ColumnType::Double => "DOUBLE PRECISION".into(),
309            ColumnType::Boolean => "BOOLEAN".into(),
310            ColumnType::Timestamp => "TIMESTAMPTZ".into(),
311            ColumnType::Date => "DATE".into(),
312            ColumnType::Jsonb => "JSONB".into(),
313            ColumnType::Numeric {
314                precision: Some(precision),
315                scale: Some(scale),
316            } => format!("NUMERIC({precision},{scale})").into(),
317            ColumnType::Numeric {
318                precision: Some(precision),
319                scale: None,
320            } => format!("NUMERIC({precision})").into(),
321            ColumnType::Numeric {
322                precision: None, ..
323            } => "NUMERIC".into(),
324            ColumnType::Varchar { length } => format!("VARCHAR({length})").into(),
325            ColumnType::TimestampNaive => "TIMESTAMP".into(),
326            ColumnType::Time => "TIME".into(),
327            ColumnType::Bytea => "BYTEA".into(),
328            ColumnType::TextArray => "TEXT[]".into(),
329            ColumnType::UuidArray => "UUID[]".into(),
330            ColumnType::IntegerArray => "INTEGER[]".into(),
331            ColumnType::BigIntegerArray => "BIGINT[]".into(),
332            ColumnType::JsonbArray => "JSONB[]".into(),
333        }
334    }
335
336    /// Whether the type needs a `column_types_v2` platform.
337    pub fn is_v2(&self) -> bool {
338        !matches!(
339            self,
340            ColumnType::Uuid
341                | ColumnType::Text
342                | ColumnType::Integer
343                | ColumnType::BigInteger
344                | ColumnType::Float
345                | ColumnType::Double
346                | ColumnType::Boolean
347                | ColumnType::Timestamp
348                | ColumnType::Date
349                | ColumnType::Jsonb
350        )
351    }
352
353    /// The `format_type()` spellings of an existing column this type may be
354    /// declared over, without their type modifiers (`numeric(12,2)` is
355    /// `numeric`).
356    pub fn pg_type_names(&self) -> &'static [&'static str] {
357        match self {
358            ColumnType::Uuid => &["uuid"],
359            ColumnType::Text => &["text", "character varying", "character", "citext"],
360            ColumnType::Integer => &["integer"],
361            ColumnType::BigInteger => &["bigint"],
362            ColumnType::Float => &["real"],
363            ColumnType::Double => &["double precision"],
364            ColumnType::Boolean => &["boolean"],
365            ColumnType::Timestamp => &["timestamp with time zone"],
366            ColumnType::Date => &["date"],
367            ColumnType::Jsonb => &["jsonb"],
368            ColumnType::Numeric { .. } => &["numeric"],
369            ColumnType::Varchar { .. } => &["character varying"],
370            ColumnType::TimestampNaive => &["timestamp without time zone"],
371            ColumnType::Time => &["time without time zone"],
372            ColumnType::Bytea => &["bytea"],
373            ColumnType::TextArray => &["text[]", "character varying[]"],
374            ColumnType::UuidArray => &["uuid[]"],
375            ColumnType::IntegerArray => &["integer[]"],
376            ColumnType::BigIntegerArray => &["bigint[]"],
377            ColumnType::JsonbArray => &["jsonb[]"],
378        }
379    }
380
381    /// The type's name in filter errors.
382    pub fn filter_type(&self) -> &str {
383        match self {
384            ColumnType::Uuid => "uuid",
385            ColumnType::Text | ColumnType::Varchar { .. } => "text",
386            ColumnType::Integer | ColumnType::BigInteger => "integer",
387            ColumnType::Float | ColumnType::Double => "double",
388            ColumnType::Boolean => "boolean",
389            ColumnType::Timestamp | ColumnType::TimestampNaive => "timestamp",
390            ColumnType::Date => "date",
391            ColumnType::Jsonb => "jsonb",
392            ColumnType::Numeric { .. } => "numeric",
393            ColumnType::Time => "time",
394            ColumnType::Bytea => "bytea",
395            ColumnType::TextArray
396            | ColumnType::UuidArray
397            | ColumnType::IntegerArray
398            | ColumnType::BigIntegerArray
399            | ColumnType::JsonbArray => "array",
400        }
401    }
402}
403
404/// CRUD lifecycle hooks for a table. Each field names a WASM handler function.
405#[derive(Debug, Clone, Serialize, Deserialize, Default)]
406pub struct TableHooks {
407    #[serde(default, skip_serializing_if = "Option::is_none")]
408    pub before_create: Option<String>,
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub after_create: Option<String>,
411    #[serde(default, skip_serializing_if = "Option::is_none")]
412    pub before_update: Option<String>,
413    #[serde(default, skip_serializing_if = "Option::is_none")]
414    pub after_update: Option<String>,
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub before_delete: Option<String>,
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub after_delete: Option<String>,
419}
420
421impl TableDefinition {
422    /// Get the primary key column
423    pub fn primary_key(&self) -> Option<&ColumnDefinition> {
424        self.columns.iter().find(|c| c.primary_key)
425    }
426
427    /// Get all non-primary-key, non-auto columns (for INSERT/UPDATE)
428    pub fn writable_columns(&self) -> Vec<&ColumnDefinition> {
429        self.columns
430            .iter()
431            .filter(|c| !c.auto_generate || !c.primary_key)
432            .collect()
433    }
434
435    /// Platform features this table's declaration requires.
436    pub fn derived_features(&self) -> impl Iterator<Item = &'static str> {
437        let types_v2 = !self.checks.is_empty()
438            || self.search.is_some()
439            || self.indexes.iter().any(IndexDefinition::is_v2)
440            || self.columns.iter().any(|c| c.column_type.is_v2());
441        let audit_redaction = !self.audit_redact.is_empty() || self.sensitive;
442        [
443            self.scoped_by
444                .is_some()
445                .then_some(features::SCOPED_PERMISSIONS),
446            (!self.managed).then_some(features::SCHEMA_ADOPTED),
447            types_v2.then_some(features::COLUMN_TYPES_V2),
448            audit_redaction.then_some(features::AUDIT_REDACTION),
449            (!self.pagination.is_offset()).then_some(features::KEYSET_LISTS),
450        ]
451        .into_iter()
452        .flatten()
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use crate::manifest::fixtures::sample_manifest;
460
461    #[test]
462    fn test_primary_key() {
463        let manifest = sample_manifest();
464        let table = manifest.get_table("todos").unwrap();
465        let pk = table.primary_key().unwrap();
466        assert_eq!(pk.name, "id");
467        assert!(pk.auto_generate);
468    }
469
470    #[test]
471    fn test_column_type_to_sql() {
472        assert_eq!(ColumnType::Uuid.to_sql(), "UUID");
473        assert_eq!(ColumnType::Text.to_sql(), "TEXT");
474        assert_eq!(ColumnType::Integer.to_sql(), "INTEGER");
475        assert_eq!(ColumnType::BigInteger.to_sql(), "BIGINT");
476        assert_eq!(ColumnType::Boolean.to_sql(), "BOOLEAN");
477        assert_eq!(ColumnType::Timestamp.to_sql(), "TIMESTAMPTZ");
478        assert_eq!(ColumnType::Date.to_sql(), "DATE");
479        assert_eq!(ColumnType::Jsonb.to_sql(), "JSONB");
480    }
481
482    fn v2_types() -> Vec<(ColumnType, serde_json::Value, &'static str)> {
483        use serde_json::json;
484        vec![
485            (
486                ColumnType::Numeric {
487                    precision: Some(12),
488                    scale: Some(2),
489                },
490                json!({"numeric": {"precision": 12, "scale": 2}}),
491                "NUMERIC(12,2)",
492            ),
493            (
494                ColumnType::Numeric {
495                    precision: Some(9),
496                    scale: None,
497                },
498                json!({"numeric": {"precision": 9}}),
499                "NUMERIC(9)",
500            ),
501            (
502                ColumnType::Numeric {
503                    precision: None,
504                    scale: None,
505                },
506                json!({"numeric": {}}),
507                "NUMERIC",
508            ),
509            (
510                ColumnType::Varchar { length: 50 },
511                json!({"varchar": {"length": 50}}),
512                "VARCHAR(50)",
513            ),
514            (
515                ColumnType::TimestampNaive,
516                json!("timestamp_naive"),
517                "TIMESTAMP",
518            ),
519            (ColumnType::Time, json!("time"), "TIME"),
520            (ColumnType::Bytea, json!("bytea"), "BYTEA"),
521            (ColumnType::TextArray, json!("text_array"), "TEXT[]"),
522            (ColumnType::UuidArray, json!("uuid_array"), "UUID[]"),
523            (
524                ColumnType::IntegerArray,
525                json!("integer_array"),
526                "INTEGER[]",
527            ),
528            (
529                ColumnType::BigIntegerArray,
530                json!("biginteger_array"),
531                "BIGINT[]",
532            ),
533            (ColumnType::JsonbArray, json!("jsonb_array"), "JSONB[]"),
534        ]
535    }
536
537    #[test]
538    fn v2_column_types_have_pinned_json_and_sql() {
539        for (column_type, wire, sql) in v2_types() {
540            assert_eq!(serde_json::to_value(&column_type).unwrap(), wire);
541            assert_eq!(
542                serde_json::from_value::<ColumnType>(wire).unwrap(),
543                column_type
544            );
545            assert_eq!(column_type.to_sql(), sql);
546            assert!(column_type.is_v2(), "{column_type:?}");
547            assert!(!column_type.pg_type_names().is_empty());
548        }
549    }
550
551    #[test]
552    fn legacy_column_types_are_unchanged() {
553        for (column_type, wire) in [
554            (ColumnType::Uuid, "uuid"),
555            (ColumnType::Text, "text"),
556            (ColumnType::Integer, "integer"),
557            (ColumnType::BigInteger, "biginteger"),
558            (ColumnType::Float, "float"),
559            (ColumnType::Double, "double"),
560            (ColumnType::Boolean, "boolean"),
561            (ColumnType::Timestamp, "timestamp"),
562            (ColumnType::Date, "date"),
563            (ColumnType::Jsonb, "jsonb"),
564        ] {
565            assert_eq!(
566                serde_json::to_value(&column_type).unwrap(),
567                serde_json::json!(wire)
568            );
569            assert!(!column_type.is_v2(), "{column_type:?}");
570        }
571        assert!(serde_json::from_str::<ColumnType>(r#""numeric""#).is_err());
572        assert!(serde_json::from_str::<ColumnType>(r#""interval""#).is_err());
573    }
574
575    #[test]
576    fn a_numeric_scale_needs_a_precision_to_reach_sql() {
577        let scale_only = ColumnType::Numeric {
578            precision: None,
579            scale: Some(2),
580        };
581        assert_eq!(scale_only.to_sql(), "NUMERIC");
582    }
583
584    fn column(name: &str, column_type: ColumnType) -> ColumnDefinition {
585        ColumnDefinition {
586            name: name.into(),
587            column_type,
588            primary_key: false,
589            nullable: true,
590            auto_generate: false,
591            default_value: None,
592            references: None,
593            on_delete: None,
594            unique: false,
595            validations: vec![],
596        }
597    }
598
599    #[test]
600    fn r3_table_fields_are_omitted_at_their_defaults() {
601        let table = TableDefinition {
602            name: "notes".into(),
603            ..Default::default()
604        };
605        assert!(table.managed);
606        assert_eq!(
607            serde_json::to_value(&table).unwrap(),
608            serde_json::json!({
609                "name": "notes",
610                "columns": [],
611                "soft_delete": false,
612                "auth_required": false,
613                "owner_self_service": false
614            })
615        );
616        let parsed: TableDefinition =
617            serde_json::from_str(r#"{"name":"notes","columns":[]}"#).unwrap();
618        assert!(parsed.managed);
619        assert_eq!(parsed.pagination, Pagination::Offset);
620        assert_eq!(table.derived_features().count(), 0);
621    }
622
623    #[test]
624    fn r3_table_fields_round_trip() {
625        let table = TableDefinition {
626            name: "lead".into(),
627            indexes: vec![IndexDefinition {
628                name: "uq_org_email".into(),
629                columns: vec!["organisation_id".into(), "email".into()],
630                unique: true,
631                predicate: Some("deleted_at IS NULL".into()),
632                method: IndexMethod::Gin,
633            }],
634            managed: false,
635            checks: vec![CheckConstraint {
636                name: "price_nonneg".into(),
637                expr: "price_cents >= 0".into(),
638            }],
639            search: Some(SearchSpec {
640                columns: vec!["name".into(), "email".into()],
641                config: "english".into(),
642                vector_column: Some("search_vector".into()),
643            }),
644            audit_redact: vec!["dob".into(), "bank_*".into()],
645            sensitive: true,
646            pagination: Pagination::Keyset,
647            ..Default::default()
648        };
649        let wire = serde_json::to_value(&table).unwrap();
650        assert_eq!(
651            wire,
652            serde_json::json!({
653                "name": "lead",
654                "columns": [],
655                "indexes": [{
656                    "name": "uq_org_email",
657                    "columns": ["organisation_id", "email"],
658                    "unique": true,
659                    "predicate": "deleted_at IS NULL",
660                    "method": "gin"
661                }],
662                "soft_delete": false,
663                "auth_required": false,
664                "owner_self_service": false,
665                "managed": false,
666                "checks": [{"name": "price_nonneg", "expr": "price_cents >= 0"}],
667                "search": {
668                    "columns": ["name", "email"],
669                    "config": "english",
670                    "vector_column": "search_vector"
671                },
672                "audit_redact": ["dob", "bank_*"],
673                "sensitive": true,
674                "pagination": "keyset"
675            })
676        );
677        let parsed: TableDefinition = serde_json::from_value(wire).unwrap();
678        assert!(!parsed.managed);
679        assert_eq!(parsed.indexes, table.indexes);
680        assert_eq!(parsed.checks, table.checks);
681        assert_eq!(parsed.search, table.search);
682        assert_eq!(parsed.audit_redact, table.audit_redact);
683        assert!(parsed.sensitive);
684        assert_eq!(parsed.pagination, Pagination::Keyset);
685    }
686
687    #[test]
688    fn a_default_search_config_is_omitted() {
689        let search = SearchSpec {
690            columns: vec!["name".into()],
691            ..Default::default()
692        };
693        let wire = serde_json::json!({"columns": ["name"]});
694        assert_eq!(serde_json::to_value(&search).unwrap(), wire);
695        assert_eq!(serde_json::from_value::<SearchSpec>(wire).unwrap(), search);
696    }
697
698    #[test]
699    fn a_legacy_index_is_unchanged() {
700        let index = IndexDefinition {
701            name: "idx_items_sku".into(),
702            columns: vec!["sku".into()],
703            ..Default::default()
704        };
705        assert_eq!(
706            serde_json::to_value(&index).unwrap(),
707            serde_json::json!({"name": "idx_items_sku", "columns": ["sku"], "unique": false})
708        );
709        assert!(!index.is_v2());
710    }
711
712    #[test]
713    fn an_index_over_several_columns_is_as_legacy_as_one_over_one() {
714        for unique in [false, true] {
715            let index = IndexDefinition {
716                name: "idx_items_branch_sku".into(),
717                columns: vec!["branch_id".into(), "sku".into()],
718                unique,
719                ..Default::default()
720            };
721            assert!(!index.is_v2(), "unique={unique}");
722            let table = TableDefinition {
723                name: "items".into(),
724                indexes: vec![index],
725                ..Default::default()
726            };
727            assert_eq!(table.derived_features().count(), 0, "unique={unique}");
728        }
729    }
730
731    #[test]
732    fn each_r3_table_field_demands_its_feature() {
733        let plain = || TableDefinition {
734            name: "t".into(),
735            ..Default::default()
736        };
737        let index = |index: IndexDefinition| TableDefinition {
738            indexes: vec![index],
739            ..plain()
740        };
741        let cases = [
742            (
743                TableDefinition {
744                    managed: false,
745                    ..plain()
746                },
747                features::SCHEMA_ADOPTED,
748            ),
749            (
750                TableDefinition {
751                    checks: vec![CheckConstraint {
752                        name: "c".into(),
753                        expr: "a > 0".into(),
754                    }],
755                    ..plain()
756                },
757                features::COLUMN_TYPES_V2,
758            ),
759            (
760                TableDefinition {
761                    search: Some(SearchSpec::default()),
762                    ..plain()
763                },
764                features::COLUMN_TYPES_V2,
765            ),
766            (
767                TableDefinition {
768                    columns: vec![column("tags", ColumnType::TextArray)],
769                    ..plain()
770                },
771                features::COLUMN_TYPES_V2,
772            ),
773            (
774                index(IndexDefinition {
775                    name: "i".into(),
776                    columns: vec!["a".into()],
777                    predicate: Some("a IS NOT NULL".into()),
778                    ..Default::default()
779                }),
780                features::COLUMN_TYPES_V2,
781            ),
782            (
783                index(IndexDefinition {
784                    name: "i".into(),
785                    columns: vec!["a".into()],
786                    method: IndexMethod::Brin,
787                    ..Default::default()
788                }),
789                features::COLUMN_TYPES_V2,
790            ),
791            (
792                TableDefinition {
793                    audit_redact: vec!["dob".into()],
794                    ..plain()
795                },
796                features::AUDIT_REDACTION,
797            ),
798            (
799                TableDefinition {
800                    sensitive: true,
801                    ..plain()
802                },
803                features::AUDIT_REDACTION,
804            ),
805            (
806                TableDefinition {
807                    pagination: Pagination::Keyset,
808                    ..plain()
809                },
810                features::KEYSET_LISTS,
811            ),
812        ];
813        for (table, feature) in cases {
814            assert_eq!(
815                table.derived_features().collect::<Vec<_>>(),
816                [feature],
817                "{table:?}"
818            );
819        }
820    }
821
822    #[test]
823    fn test_fk_not_serialized_when_none() {
824        let col = ColumnDefinition {
825            name: "title".into(),
826            column_type: ColumnType::Text,
827            primary_key: false,
828            nullable: false,
829            auto_generate: false,
830            default_value: None,
831            references: None,
832            on_delete: None,
833            unique: false,
834            validations: vec![],
835        };
836        let json = serde_json::to_string(&col).unwrap();
837        assert!(!json.contains("references"));
838        assert!(!json.contains("on_delete"));
839    }
840
841    #[test]
842    fn test_unique_column_serialization() {
843        let col = ColumnDefinition {
844            name: "email".into(),
845            column_type: ColumnType::Text,
846            primary_key: false,
847            nullable: false,
848            auto_generate: false,
849            default_value: None,
850            references: None,
851            on_delete: None,
852            unique: true,
853            validations: vec![],
854        };
855        let json = serde_json::to_string(&col).unwrap();
856        assert!(json.contains("\"unique\":true"));
857        let parsed: ColumnDefinition = serde_json::from_str(&json).unwrap();
858        assert!(parsed.unique);
859    }
860
861    #[test]
862    fn test_index_serialization() {
863        let table = TableDefinition {
864            name: "users".into(),
865            columns: vec![],
866            indexes: vec![IndexDefinition {
867                name: "idx_users_email".into(),
868                columns: vec!["email".into()],
869                unique: true,
870                ..Default::default()
871            }],
872            soft_delete: false,
873            owner_field: None,
874            auth_required: false,
875            permission_area: None,
876            owner_self_service: false,
877            hooks: None,
878            scoped_by: None,
879            ..Default::default()
880        };
881        let json = serde_json::to_string(&table).unwrap();
882        assert!(json.contains("idx_users_email"));
883        let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
884        assert_eq!(parsed.indexes.len(), 1);
885        assert!(parsed.indexes[0].unique);
886    }
887
888    #[test]
889    fn test_indexes_not_serialized_when_empty() {
890        let table = TableDefinition {
891            name: "users".into(),
892            columns: vec![],
893            indexes: vec![],
894            soft_delete: false,
895            owner_field: None,
896            auth_required: false,
897            permission_area: None,
898            owner_self_service: false,
899            hooks: None,
900            scoped_by: None,
901            ..Default::default()
902        };
903        let json = serde_json::to_string(&table).unwrap();
904        assert!(!json.contains("indexes"));
905    }
906
907    #[test]
908    fn test_owner_field_serialization() {
909        let table = TableDefinition {
910            name: "notes".into(),
911            columns: vec![],
912            indexes: vec![],
913            soft_delete: false,
914            owner_field: Some("user_id".into()),
915            auth_required: false,
916            permission_area: None,
917            owner_self_service: false,
918            hooks: None,
919            scoped_by: None,
920            ..Default::default()
921        };
922        let json = serde_json::to_string(&table).unwrap();
923        assert!(json.contains("\"owner_field\":\"user_id\""));
924        let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
925        assert_eq!(parsed.owner_field.as_deref(), Some("user_id"));
926    }
927
928    #[test]
929    fn test_owner_field_not_serialized_when_none() {
930        let table = TableDefinition {
931            name: "notes".into(),
932            columns: vec![],
933            indexes: vec![],
934            soft_delete: false,
935            owner_field: None,
936            auth_required: false,
937            permission_area: None,
938            owner_self_service: false,
939            hooks: None,
940            scoped_by: None,
941            ..Default::default()
942        };
943        let json = serde_json::to_string(&table).unwrap();
944        assert!(!json.contains("owner_field"));
945    }
946
947    #[test]
948    fn test_owner_field_defaults_to_none() {
949        let json = r#"{"name":"notes","columns":[]}"#;
950        let table: TableDefinition = serde_json::from_str(json).unwrap();
951        assert!(table.owner_field.is_none());
952    }
953
954    #[test]
955    fn test_auth_required_serialization() {
956        let table = TableDefinition {
957            name: "orders".into(),
958            columns: vec![],
959            indexes: vec![],
960            soft_delete: false,
961            owner_field: None,
962            auth_required: true,
963            permission_area: None,
964            owner_self_service: false,
965            hooks: None,
966            scoped_by: None,
967            ..Default::default()
968        };
969        let json = serde_json::to_string(&table).unwrap();
970        assert!(json.contains("\"auth_required\":true"));
971        let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
972        assert!(parsed.auth_required);
973    }
974
975    #[test]
976    fn test_auth_required_defaults_to_false() {
977        let json = r#"{"name":"orders","columns":[]}"#;
978        let table: TableDefinition = serde_json::from_str(json).unwrap();
979        assert!(!table.auth_required);
980    }
981
982    #[test]
983    fn scoped_by_is_omitted_when_absent_and_derives_its_feature() {
984        let table: TableDefinition =
985            serde_json::from_str(r#"{"name":"leads","columns":[],"owner_field":"user_id"}"#)
986                .unwrap();
987        assert!(table.scoped_by.is_none());
988        assert!(!serde_json::to_string(&table).unwrap().contains("scoped_by"));
989        assert_eq!(table.derived_features().count(), 0);
990
991        let scoped = TableDefinition {
992            scoped_by: Some("leads".into()),
993            ..table
994        };
995        let json = serde_json::to_string(&scoped).unwrap();
996        assert!(json.contains(r#""scoped_by":"leads""#), "{json}");
997        assert_eq!(
998            scoped.derived_features().collect::<Vec<_>>(),
999            [crate::features::SCOPED_PERMISSIONS]
1000        );
1001    }
1002}