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    /// The grants the data routes ask of a caller for the column's values.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub access: Option<ColumnAccess>,
179}
180
181/// The operations of the table's permission area a caller is granted to
182/// read a column's values through the data routes, and to write them.
183/// Owning the row grants neither.
184#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
185pub struct ColumnAccess {
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub read: Option<String>,
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub write: Option<String>,
190}
191
192impl ColumnAccess {
193    /// The access with each operation as the permission of `area` a role
194    /// grants: `{area}:{operation}`.
195    pub fn in_area(&self, area: &str) -> ColumnAccess {
196        let permission = |operation: &Option<String>| {
197            operation
198                .as_ref()
199                .map(|operation| format!("{area}:{operation}"))
200        };
201        ColumnAccess {
202            read: permission(&self.read),
203            write: permission(&self.write),
204        }
205    }
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
209#[serde(tag = "rule", rename_all = "snake_case")]
210// Note: ValidationRule contains f64 fields (Min/Max), so Eq cannot be derived
211pub enum ValidationRule {
212    /// Regex pattern match
213    Regex { pattern: String },
214    /// Minimum numeric value
215    Min { value: f64 },
216    /// Maximum numeric value
217    Max { value: f64 },
218    /// Minimum string length
219    MinLength { value: usize },
220    /// Maximum string length
221    MaxLength { value: usize },
222    /// Must be one of the listed values
223    OneOf { values: Vec<String> },
224}
225
226#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
227pub struct IndexDefinition {
228    pub name: String,
229    pub columns: Vec<String>,
230    #[serde(default)]
231    pub unique: bool,
232    /// The SQL condition of a partial index.
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub predicate: Option<String>,
235    #[serde(default, skip_serializing_if = "IndexMethod::is_btree")]
236    pub method: IndexMethod,
237}
238
239impl IndexDefinition {
240    /// Whether the index has a shape only a `column_types_v2` platform
241    /// builds. An index over several columns, unique or not, is not one:
242    /// platforms built them before the feature existed.
243    pub fn is_v2(&self) -> bool {
244        self.predicate.is_some() || !self.method.is_btree()
245    }
246}
247
248#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(rename_all = "snake_case")]
250pub enum IndexMethod {
251    #[default]
252    Btree,
253    Gin,
254    Gist,
255    Hash,
256    Brin,
257}
258
259impl IndexMethod {
260    pub fn is_btree(&self) -> bool {
261        *self == IndexMethod::Btree
262    }
263
264    pub fn as_sql(self) -> &'static str {
265        match self {
266            IndexMethod::Btree => "btree",
267            IndexMethod::Gin => "gin",
268            IndexMethod::Gist => "gist",
269            IndexMethod::Hash => "hash",
270            IndexMethod::Brin => "brin",
271        }
272    }
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
276#[serde(rename_all = "snake_case")]
277pub enum ForeignKeyAction {
278    Cascade,
279    SetNull,
280    Restrict,
281    NoAction,
282}
283
284#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
285#[serde(rename_all = "lowercase")]
286pub enum ColumnType {
287    Uuid,
288    #[default]
289    Text,
290    Integer,
291    BigInteger,
292    Float,
293    Double,
294    Boolean,
295    Timestamp,
296    Date,
297    Jsonb,
298    #[serde(rename = "numeric")]
299    Numeric {
300        #[serde(default, skip_serializing_if = "Option::is_none")]
301        precision: Option<u16>,
302        #[serde(default, skip_serializing_if = "Option::is_none")]
303        scale: Option<u16>,
304    },
305    #[serde(rename = "varchar")]
306    Varchar {
307        length: u32,
308    },
309    #[serde(rename = "timestamp_naive")]
310    TimestampNaive,
311    #[serde(rename = "time")]
312    Time,
313    #[serde(rename = "bytea")]
314    Bytea,
315    #[serde(rename = "text_array")]
316    TextArray,
317    #[serde(rename = "uuid_array")]
318    UuidArray,
319    #[serde(rename = "integer_array")]
320    IntegerArray,
321    #[serde(rename = "biginteger_array")]
322    BigIntegerArray,
323    #[serde(rename = "jsonb_array")]
324    JsonbArray,
325}
326
327impl ColumnType {
328    pub const MAX_NUMERIC_PRECISION: u16 = 1000;
329    pub const MAX_VARCHAR_LENGTH: u32 = 10_485_760;
330
331    pub fn to_sql(&self) -> Cow<'static, str> {
332        match self {
333            ColumnType::Uuid => "UUID".into(),
334            ColumnType::Text => "TEXT".into(),
335            ColumnType::Integer => "INTEGER".into(),
336            ColumnType::BigInteger => "BIGINT".into(),
337            ColumnType::Float => "REAL".into(),
338            ColumnType::Double => "DOUBLE PRECISION".into(),
339            ColumnType::Boolean => "BOOLEAN".into(),
340            ColumnType::Timestamp => "TIMESTAMPTZ".into(),
341            ColumnType::Date => "DATE".into(),
342            ColumnType::Jsonb => "JSONB".into(),
343            ColumnType::Numeric {
344                precision: Some(precision),
345                scale: Some(scale),
346            } => format!("NUMERIC({precision},{scale})").into(),
347            ColumnType::Numeric {
348                precision: Some(precision),
349                scale: None,
350            } => format!("NUMERIC({precision})").into(),
351            ColumnType::Numeric {
352                precision: None, ..
353            } => "NUMERIC".into(),
354            ColumnType::Varchar { length } => format!("VARCHAR({length})").into(),
355            ColumnType::TimestampNaive => "TIMESTAMP".into(),
356            ColumnType::Time => "TIME".into(),
357            ColumnType::Bytea => "BYTEA".into(),
358            ColumnType::TextArray => "TEXT[]".into(),
359            ColumnType::UuidArray => "UUID[]".into(),
360            ColumnType::IntegerArray => "INTEGER[]".into(),
361            ColumnType::BigIntegerArray => "BIGINT[]".into(),
362            ColumnType::JsonbArray => "JSONB[]".into(),
363        }
364    }
365
366    /// Whether the type needs a `column_types_v2` platform.
367    pub fn is_v2(&self) -> bool {
368        !matches!(
369            self,
370            ColumnType::Uuid
371                | ColumnType::Text
372                | ColumnType::Integer
373                | ColumnType::BigInteger
374                | ColumnType::Float
375                | ColumnType::Double
376                | ColumnType::Boolean
377                | ColumnType::Timestamp
378                | ColumnType::Date
379                | ColumnType::Jsonb
380        )
381    }
382
383    /// The `format_type()` spellings of an existing column this type may be
384    /// declared over, without their type modifiers (`numeric(12,2)` is
385    /// `numeric`).
386    pub fn pg_type_names(&self) -> &'static [&'static str] {
387        match self {
388            ColumnType::Uuid => &["uuid"],
389            ColumnType::Text => &["text", "character varying", "character", "citext"],
390            ColumnType::Integer => &["integer"],
391            ColumnType::BigInteger => &["bigint"],
392            ColumnType::Float => &["real"],
393            ColumnType::Double => &["double precision"],
394            ColumnType::Boolean => &["boolean"],
395            ColumnType::Timestamp => &["timestamp with time zone"],
396            ColumnType::Date => &["date"],
397            ColumnType::Jsonb => &["jsonb"],
398            ColumnType::Numeric { .. } => &["numeric"],
399            ColumnType::Varchar { .. } => &["character varying"],
400            ColumnType::TimestampNaive => &["timestamp without time zone"],
401            ColumnType::Time => &["time without time zone"],
402            ColumnType::Bytea => &["bytea"],
403            ColumnType::TextArray => &["text[]", "character varying[]"],
404            ColumnType::UuidArray => &["uuid[]"],
405            ColumnType::IntegerArray => &["integer[]"],
406            ColumnType::BigIntegerArray => &["bigint[]"],
407            ColumnType::JsonbArray => &["jsonb[]"],
408        }
409    }
410
411    /// The type's name in filter errors.
412    pub fn filter_type(&self) -> &str {
413        match self {
414            ColumnType::Uuid => "uuid",
415            ColumnType::Text | ColumnType::Varchar { .. } => "text",
416            ColumnType::Integer | ColumnType::BigInteger => "integer",
417            ColumnType::Float | ColumnType::Double => "double",
418            ColumnType::Boolean => "boolean",
419            ColumnType::Timestamp | ColumnType::TimestampNaive => "timestamp",
420            ColumnType::Date => "date",
421            ColumnType::Jsonb => "jsonb",
422            ColumnType::Numeric { .. } => "numeric",
423            ColumnType::Time => "time",
424            ColumnType::Bytea => "bytea",
425            ColumnType::TextArray
426            | ColumnType::UuidArray
427            | ColumnType::IntegerArray
428            | ColumnType::BigIntegerArray
429            | ColumnType::JsonbArray => "array",
430        }
431    }
432}
433
434/// CRUD lifecycle hooks for a table. Each field names a WASM handler function.
435#[derive(Debug, Clone, Serialize, Deserialize, Default)]
436pub struct TableHooks {
437    #[serde(default, skip_serializing_if = "Option::is_none")]
438    pub before_create: Option<String>,
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    pub after_create: Option<String>,
441    #[serde(default, skip_serializing_if = "Option::is_none")]
442    pub before_update: Option<String>,
443    #[serde(default, skip_serializing_if = "Option::is_none")]
444    pub after_update: Option<String>,
445    #[serde(default, skip_serializing_if = "Option::is_none")]
446    pub before_delete: Option<String>,
447    #[serde(default, skip_serializing_if = "Option::is_none")]
448    pub after_delete: Option<String>,
449}
450
451impl TableDefinition {
452    /// Get the primary key column
453    pub fn primary_key(&self) -> Option<&ColumnDefinition> {
454        self.columns.iter().find(|c| c.primary_key)
455    }
456
457    /// Get all non-primary-key, non-auto columns (for INSERT/UPDATE)
458    pub fn writable_columns(&self) -> Vec<&ColumnDefinition> {
459        self.columns
460            .iter()
461            .filter(|c| !c.auto_generate || !c.primary_key)
462            .collect()
463    }
464
465    /// The permission area whose operations the table's column permissions
466    /// name.
467    pub fn access_area(&self) -> Option<&str> {
468        self.scoped_by
469            .as_deref()
470            .or(self.permission_area.as_deref())
471    }
472
473    /// The columns that ask a grant of a caller, each with what it asks.
474    pub fn restricted_columns(&self) -> impl Iterator<Item = (&ColumnDefinition, &ColumnAccess)> {
475        self.columns
476            .iter()
477            .filter_map(|column| column.access.as_ref().map(|access| (column, access)))
478    }
479
480    /// The permissions `column` asks of a caller, where it asks any.
481    pub fn permissions_of(&self, column: &ColumnDefinition) -> Option<ColumnAccess> {
482        column
483            .access
484            .as_ref()
485            .zip(self.access_area())
486            .map(|(access, area)| access.in_area(area))
487    }
488
489    /// Whether some callers are answered rows without `column`.
490    pub fn withholds(&self, column: &ColumnDefinition) -> bool {
491        self.permissions_of(column)
492            .is_some_and(|permissions| permissions.read.is_some())
493    }
494
495    /// Platform features this table's declaration requires.
496    pub fn derived_features(&self) -> impl Iterator<Item = &'static str> {
497        let types_v2 = !self.checks.is_empty()
498            || self.search.is_some()
499            || self.indexes.iter().any(IndexDefinition::is_v2)
500            || self.columns.iter().any(|c| c.column_type.is_v2());
501        let audit_redaction = !self.audit_redact.is_empty() || self.sensitive;
502        [
503            self.scoped_by
504                .is_some()
505                .then_some(features::SCOPED_PERMISSIONS),
506            (!self.managed).then_some(features::SCHEMA_ADOPTED),
507            types_v2.then_some(features::COLUMN_TYPES_V2),
508            audit_redaction.then_some(features::AUDIT_REDACTION),
509            (!self.pagination.is_offset()).then_some(features::KEYSET_LISTS),
510            self.restricted_columns()
511                .next()
512                .map(|_| features::COLUMN_PERMISSIONS),
513        ]
514        .into_iter()
515        .flatten()
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use crate::manifest::fixtures::sample_manifest;
523
524    #[test]
525    fn test_primary_key() {
526        let manifest = sample_manifest();
527        let table = manifest.get_table("todos").unwrap();
528        let pk = table.primary_key().unwrap();
529        assert_eq!(pk.name, "id");
530        assert!(pk.auto_generate);
531    }
532
533    #[test]
534    fn test_column_type_to_sql() {
535        assert_eq!(ColumnType::Uuid.to_sql(), "UUID");
536        assert_eq!(ColumnType::Text.to_sql(), "TEXT");
537        assert_eq!(ColumnType::Integer.to_sql(), "INTEGER");
538        assert_eq!(ColumnType::BigInteger.to_sql(), "BIGINT");
539        assert_eq!(ColumnType::Boolean.to_sql(), "BOOLEAN");
540        assert_eq!(ColumnType::Timestamp.to_sql(), "TIMESTAMPTZ");
541        assert_eq!(ColumnType::Date.to_sql(), "DATE");
542        assert_eq!(ColumnType::Jsonb.to_sql(), "JSONB");
543    }
544
545    fn v2_types() -> Vec<(ColumnType, serde_json::Value, &'static str)> {
546        use serde_json::json;
547        vec![
548            (
549                ColumnType::Numeric {
550                    precision: Some(12),
551                    scale: Some(2),
552                },
553                json!({"numeric": {"precision": 12, "scale": 2}}),
554                "NUMERIC(12,2)",
555            ),
556            (
557                ColumnType::Numeric {
558                    precision: Some(9),
559                    scale: None,
560                },
561                json!({"numeric": {"precision": 9}}),
562                "NUMERIC(9)",
563            ),
564            (
565                ColumnType::Numeric {
566                    precision: None,
567                    scale: None,
568                },
569                json!({"numeric": {}}),
570                "NUMERIC",
571            ),
572            (
573                ColumnType::Varchar { length: 50 },
574                json!({"varchar": {"length": 50}}),
575                "VARCHAR(50)",
576            ),
577            (
578                ColumnType::TimestampNaive,
579                json!("timestamp_naive"),
580                "TIMESTAMP",
581            ),
582            (ColumnType::Time, json!("time"), "TIME"),
583            (ColumnType::Bytea, json!("bytea"), "BYTEA"),
584            (ColumnType::TextArray, json!("text_array"), "TEXT[]"),
585            (ColumnType::UuidArray, json!("uuid_array"), "UUID[]"),
586            (
587                ColumnType::IntegerArray,
588                json!("integer_array"),
589                "INTEGER[]",
590            ),
591            (
592                ColumnType::BigIntegerArray,
593                json!("biginteger_array"),
594                "BIGINT[]",
595            ),
596            (ColumnType::JsonbArray, json!("jsonb_array"), "JSONB[]"),
597        ]
598    }
599
600    #[test]
601    fn v2_column_types_have_pinned_json_and_sql() {
602        for (column_type, wire, sql) in v2_types() {
603            assert_eq!(serde_json::to_value(&column_type).unwrap(), wire);
604            assert_eq!(
605                serde_json::from_value::<ColumnType>(wire).unwrap(),
606                column_type
607            );
608            assert_eq!(column_type.to_sql(), sql);
609            assert!(column_type.is_v2(), "{column_type:?}");
610            assert!(!column_type.pg_type_names().is_empty());
611        }
612    }
613
614    #[test]
615    fn legacy_column_types_are_unchanged() {
616        for (column_type, wire) in [
617            (ColumnType::Uuid, "uuid"),
618            (ColumnType::Text, "text"),
619            (ColumnType::Integer, "integer"),
620            (ColumnType::BigInteger, "biginteger"),
621            (ColumnType::Float, "float"),
622            (ColumnType::Double, "double"),
623            (ColumnType::Boolean, "boolean"),
624            (ColumnType::Timestamp, "timestamp"),
625            (ColumnType::Date, "date"),
626            (ColumnType::Jsonb, "jsonb"),
627        ] {
628            assert_eq!(
629                serde_json::to_value(&column_type).unwrap(),
630                serde_json::json!(wire)
631            );
632            assert!(!column_type.is_v2(), "{column_type:?}");
633        }
634        assert!(serde_json::from_str::<ColumnType>(r#""numeric""#).is_err());
635        assert!(serde_json::from_str::<ColumnType>(r#""interval""#).is_err());
636    }
637
638    #[test]
639    fn a_numeric_scale_needs_a_precision_to_reach_sql() {
640        let scale_only = ColumnType::Numeric {
641            precision: None,
642            scale: Some(2),
643        };
644        assert_eq!(scale_only.to_sql(), "NUMERIC");
645    }
646
647    fn column(name: &str, column_type: ColumnType) -> ColumnDefinition {
648        ColumnDefinition {
649            name: name.into(),
650            column_type,
651            primary_key: false,
652            nullable: true,
653            auto_generate: false,
654            default_value: None,
655            references: None,
656            on_delete: None,
657            unique: false,
658            validations: vec![],
659            ..Default::default()
660        }
661    }
662
663    #[test]
664    fn r3_table_fields_are_omitted_at_their_defaults() {
665        let table = TableDefinition {
666            name: "notes".into(),
667            ..Default::default()
668        };
669        assert!(table.managed);
670        assert_eq!(
671            serde_json::to_value(&table).unwrap(),
672            serde_json::json!({
673                "name": "notes",
674                "columns": [],
675                "soft_delete": false,
676                "auth_required": false,
677                "owner_self_service": false
678            })
679        );
680        let parsed: TableDefinition =
681            serde_json::from_str(r#"{"name":"notes","columns":[]}"#).unwrap();
682        assert!(parsed.managed);
683        assert_eq!(parsed.pagination, Pagination::Offset);
684        assert_eq!(table.derived_features().count(), 0);
685    }
686
687    #[test]
688    fn r3_table_fields_round_trip() {
689        let table = TableDefinition {
690            name: "lead".into(),
691            indexes: vec![IndexDefinition {
692                name: "uq_org_email".into(),
693                columns: vec!["organisation_id".into(), "email".into()],
694                unique: true,
695                predicate: Some("deleted_at IS NULL".into()),
696                method: IndexMethod::Gin,
697            }],
698            managed: false,
699            checks: vec![CheckConstraint {
700                name: "price_nonneg".into(),
701                expr: "price_cents >= 0".into(),
702            }],
703            search: Some(SearchSpec {
704                columns: vec!["name".into(), "email".into()],
705                config: "english".into(),
706                vector_column: Some("search_vector".into()),
707            }),
708            audit_redact: vec!["dob".into(), "bank_*".into()],
709            sensitive: true,
710            pagination: Pagination::Keyset,
711            ..Default::default()
712        };
713        let wire = serde_json::to_value(&table).unwrap();
714        assert_eq!(
715            wire,
716            serde_json::json!({
717                "name": "lead",
718                "columns": [],
719                "indexes": [{
720                    "name": "uq_org_email",
721                    "columns": ["organisation_id", "email"],
722                    "unique": true,
723                    "predicate": "deleted_at IS NULL",
724                    "method": "gin"
725                }],
726                "soft_delete": false,
727                "auth_required": false,
728                "owner_self_service": false,
729                "managed": false,
730                "checks": [{"name": "price_nonneg", "expr": "price_cents >= 0"}],
731                "search": {
732                    "columns": ["name", "email"],
733                    "config": "english",
734                    "vector_column": "search_vector"
735                },
736                "audit_redact": ["dob", "bank_*"],
737                "sensitive": true,
738                "pagination": "keyset"
739            })
740        );
741        let parsed: TableDefinition = serde_json::from_value(wire).unwrap();
742        assert!(!parsed.managed);
743        assert_eq!(parsed.indexes, table.indexes);
744        assert_eq!(parsed.checks, table.checks);
745        assert_eq!(parsed.search, table.search);
746        assert_eq!(parsed.audit_redact, table.audit_redact);
747        assert!(parsed.sensitive);
748        assert_eq!(parsed.pagination, Pagination::Keyset);
749    }
750
751    #[test]
752    fn a_default_search_config_is_omitted() {
753        let search = SearchSpec {
754            columns: vec!["name".into()],
755            ..Default::default()
756        };
757        let wire = serde_json::json!({"columns": ["name"]});
758        assert_eq!(serde_json::to_value(&search).unwrap(), wire);
759        assert_eq!(serde_json::from_value::<SearchSpec>(wire).unwrap(), search);
760    }
761
762    #[test]
763    fn a_legacy_index_is_unchanged() {
764        let index = IndexDefinition {
765            name: "idx_items_sku".into(),
766            columns: vec!["sku".into()],
767            ..Default::default()
768        };
769        assert_eq!(
770            serde_json::to_value(&index).unwrap(),
771            serde_json::json!({"name": "idx_items_sku", "columns": ["sku"], "unique": false})
772        );
773        assert!(!index.is_v2());
774    }
775
776    #[test]
777    fn an_index_over_several_columns_is_as_legacy_as_one_over_one() {
778        for unique in [false, true] {
779            let index = IndexDefinition {
780                name: "idx_items_branch_sku".into(),
781                columns: vec!["branch_id".into(), "sku".into()],
782                unique,
783                ..Default::default()
784            };
785            assert!(!index.is_v2(), "unique={unique}");
786            let table = TableDefinition {
787                name: "items".into(),
788                indexes: vec![index],
789                ..Default::default()
790            };
791            assert_eq!(table.derived_features().count(), 0, "unique={unique}");
792        }
793    }
794
795    #[test]
796    fn each_r3_table_field_demands_its_feature() {
797        let plain = || TableDefinition {
798            name: "t".into(),
799            ..Default::default()
800        };
801        let index = |index: IndexDefinition| TableDefinition {
802            indexes: vec![index],
803            ..plain()
804        };
805        let cases = [
806            (
807                TableDefinition {
808                    managed: false,
809                    ..plain()
810                },
811                features::SCHEMA_ADOPTED,
812            ),
813            (
814                TableDefinition {
815                    checks: vec![CheckConstraint {
816                        name: "c".into(),
817                        expr: "a > 0".into(),
818                    }],
819                    ..plain()
820                },
821                features::COLUMN_TYPES_V2,
822            ),
823            (
824                TableDefinition {
825                    search: Some(SearchSpec::default()),
826                    ..plain()
827                },
828                features::COLUMN_TYPES_V2,
829            ),
830            (
831                TableDefinition {
832                    columns: vec![column("tags", ColumnType::TextArray)],
833                    ..plain()
834                },
835                features::COLUMN_TYPES_V2,
836            ),
837            (
838                index(IndexDefinition {
839                    name: "i".into(),
840                    columns: vec!["a".into()],
841                    predicate: Some("a IS NOT NULL".into()),
842                    ..Default::default()
843                }),
844                features::COLUMN_TYPES_V2,
845            ),
846            (
847                index(IndexDefinition {
848                    name: "i".into(),
849                    columns: vec!["a".into()],
850                    method: IndexMethod::Brin,
851                    ..Default::default()
852                }),
853                features::COLUMN_TYPES_V2,
854            ),
855            (
856                TableDefinition {
857                    audit_redact: vec!["dob".into()],
858                    ..plain()
859                },
860                features::AUDIT_REDACTION,
861            ),
862            (
863                TableDefinition {
864                    sensitive: true,
865                    ..plain()
866                },
867                features::AUDIT_REDACTION,
868            ),
869            (
870                TableDefinition {
871                    pagination: Pagination::Keyset,
872                    ..plain()
873                },
874                features::KEYSET_LISTS,
875            ),
876        ];
877        for (table, feature) in cases {
878            assert_eq!(
879                table.derived_features().collect::<Vec<_>>(),
880                [feature],
881                "{table:?}"
882            );
883        }
884    }
885
886    #[test]
887    fn test_fk_not_serialized_when_none() {
888        let col = ColumnDefinition {
889            name: "title".into(),
890            column_type: ColumnType::Text,
891            primary_key: false,
892            nullable: false,
893            auto_generate: false,
894            default_value: None,
895            references: None,
896            on_delete: None,
897            unique: false,
898            validations: vec![],
899            ..Default::default()
900        };
901        let json = serde_json::to_string(&col).unwrap();
902        assert!(!json.contains("references"));
903        assert!(!json.contains("on_delete"));
904    }
905
906    #[test]
907    fn test_unique_column_serialization() {
908        let col = ColumnDefinition {
909            name: "email".into(),
910            column_type: ColumnType::Text,
911            primary_key: false,
912            nullable: false,
913            auto_generate: false,
914            default_value: None,
915            references: None,
916            on_delete: None,
917            unique: true,
918            validations: vec![],
919            ..Default::default()
920        };
921        let json = serde_json::to_string(&col).unwrap();
922        assert!(json.contains("\"unique\":true"));
923        let parsed: ColumnDefinition = serde_json::from_str(&json).unwrap();
924        assert!(parsed.unique);
925    }
926
927    #[test]
928    fn test_index_serialization() {
929        let table = TableDefinition {
930            name: "users".into(),
931            columns: vec![],
932            indexes: vec![IndexDefinition {
933                name: "idx_users_email".into(),
934                columns: vec!["email".into()],
935                unique: true,
936                ..Default::default()
937            }],
938            soft_delete: false,
939            owner_field: None,
940            auth_required: false,
941            permission_area: None,
942            owner_self_service: false,
943            hooks: None,
944            scoped_by: None,
945            ..Default::default()
946        };
947        let json = serde_json::to_string(&table).unwrap();
948        assert!(json.contains("idx_users_email"));
949        let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
950        assert_eq!(parsed.indexes.len(), 1);
951        assert!(parsed.indexes[0].unique);
952    }
953
954    #[test]
955    fn test_indexes_not_serialized_when_empty() {
956        let table = TableDefinition {
957            name: "users".into(),
958            columns: vec![],
959            indexes: vec![],
960            soft_delete: false,
961            owner_field: None,
962            auth_required: false,
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("indexes"));
971    }
972
973    #[test]
974    fn test_owner_field_serialization() {
975        let table = TableDefinition {
976            name: "notes".into(),
977            columns: vec![],
978            indexes: vec![],
979            soft_delete: false,
980            owner_field: Some("user_id".into()),
981            auth_required: false,
982            permission_area: None,
983            owner_self_service: false,
984            hooks: None,
985            scoped_by: None,
986            ..Default::default()
987        };
988        let json = serde_json::to_string(&table).unwrap();
989        assert!(json.contains("\"owner_field\":\"user_id\""));
990        let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
991        assert_eq!(parsed.owner_field.as_deref(), Some("user_id"));
992    }
993
994    #[test]
995    fn test_owner_field_not_serialized_when_none() {
996        let table = TableDefinition {
997            name: "notes".into(),
998            columns: vec![],
999            indexes: vec![],
1000            soft_delete: false,
1001            owner_field: None,
1002            auth_required: false,
1003            permission_area: None,
1004            owner_self_service: false,
1005            hooks: None,
1006            scoped_by: None,
1007            ..Default::default()
1008        };
1009        let json = serde_json::to_string(&table).unwrap();
1010        assert!(!json.contains("owner_field"));
1011    }
1012
1013    #[test]
1014    fn test_owner_field_defaults_to_none() {
1015        let json = r#"{"name":"notes","columns":[]}"#;
1016        let table: TableDefinition = serde_json::from_str(json).unwrap();
1017        assert!(table.owner_field.is_none());
1018    }
1019
1020    #[test]
1021    fn test_auth_required_serialization() {
1022        let table = TableDefinition {
1023            name: "orders".into(),
1024            columns: vec![],
1025            indexes: vec![],
1026            soft_delete: false,
1027            owner_field: None,
1028            auth_required: true,
1029            permission_area: None,
1030            owner_self_service: false,
1031            hooks: None,
1032            scoped_by: None,
1033            ..Default::default()
1034        };
1035        let json = serde_json::to_string(&table).unwrap();
1036        assert!(json.contains("\"auth_required\":true"));
1037        let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
1038        assert!(parsed.auth_required);
1039    }
1040
1041    #[test]
1042    fn test_auth_required_defaults_to_false() {
1043        let json = r#"{"name":"orders","columns":[]}"#;
1044        let table: TableDefinition = serde_json::from_str(json).unwrap();
1045        assert!(!table.auth_required);
1046    }
1047
1048    #[test]
1049    fn scoped_by_is_omitted_when_absent_and_derives_its_feature() {
1050        let table: TableDefinition =
1051            serde_json::from_str(r#"{"name":"leads","columns":[],"owner_field":"user_id"}"#)
1052                .unwrap();
1053        assert!(table.scoped_by.is_none());
1054        assert!(!serde_json::to_string(&table).unwrap().contains("scoped_by"));
1055        assert_eq!(table.derived_features().count(), 0);
1056
1057        let scoped = TableDefinition {
1058            scoped_by: Some("leads".into()),
1059            ..table
1060        };
1061        let json = serde_json::to_string(&scoped).unwrap();
1062        assert!(json.contains(r#""scoped_by":"leads""#), "{json}");
1063        assert_eq!(
1064            scoped.derived_features().collect::<Vec<_>>(),
1065            [crate::features::SCOPED_PERMISSIONS]
1066        );
1067    }
1068}