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