Skip to main content

cufflink_types/manifest/
table.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize)]
4pub struct TableDefinition {
5    pub name: String,
6    pub columns: Vec<ColumnDefinition>,
7    #[serde(default, skip_serializing_if = "Vec::is_empty")]
8    pub indexes: Vec<IndexDefinition>,
9    #[serde(default)]
10    pub soft_delete: bool,
11    /// Column name that stores the owner's user ID for row-level security.
12    /// When set, CRUD operations are scoped to the authenticated user's records.
13    #[serde(default, skip_serializing_if = "Option::is_none")]
14    pub owner_field: Option<String>,
15    /// When true, all CRUD operations on this table require authentication.
16    /// Unauthenticated requests receive a 401 Unauthorized response.
17    #[serde(default)]
18    pub auth_required: bool,
19    /// Permission area that controls access to this table's CRUD operations.
20    /// When set, platform checks "{area}:view" for GET, "{area}:create" for POST,
21    /// "{area}:edit" for PUT, "{area}:delete" for DELETE.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub permission_area: Option<String>,
24    /// Self-service ownership: when true (requires `owner_field`), owning a row
25    /// authorizes access to it — the row-level scoping by `owner_field` IS the
26    /// authorization, so the `permission_area` grant is NOT required for these
27    /// (inherently owner-scoped) CRUD operations. Use for user-owned data a user
28    /// manages themselves (their items, addresses, orders). Defaults false, so
29    /// existing tables keep requiring `permission_area`; cross-owner/staff access
30    /// stays gated by RBAC (typically via dedicated handlers, not owner-scoped CRUD).
31    #[serde(default)]
32    pub owner_self_service: bool,
33    /// CRUD lifecycle hooks — WASM handler names called before/after operations.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub hooks: Option<TableHooks>,
36    /// Scoped permissions: the permission area whose grants' scopes
37    /// (`@own`, `@field(column=claim)`, `@all`) select the rows a caller
38    /// reaches. Requires `owner_field` (the `@own` column); `permission_area`
39    /// defaults to it. Without it the table behaves as before scopes existed.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub scoped_by: Option<String>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ColumnDefinition {
46    pub name: String,
47    pub column_type: ColumnType,
48    #[serde(default)]
49    pub primary_key: bool,
50    #[serde(default)]
51    pub nullable: bool,
52    #[serde(default)]
53    pub auto_generate: bool,
54    pub default_value: Option<String>,
55    /// Foreign key reference in "table.column" format
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub references: Option<String>,
58    /// Whether to cascade deletes on the FK
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub on_delete: Option<ForeignKeyAction>,
61    /// Whether this column has a unique constraint
62    #[serde(default)]
63    pub unique: bool,
64    /// Validation rules for this column
65    #[serde(default, skip_serializing_if = "Vec::is_empty")]
66    pub validations: Vec<ValidationRule>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
70#[serde(tag = "rule", rename_all = "snake_case")]
71// Note: ValidationRule contains f64 fields (Min/Max), so Eq cannot be derived
72pub enum ValidationRule {
73    /// Regex pattern match
74    Regex { pattern: String },
75    /// Minimum numeric value
76    Min { value: f64 },
77    /// Maximum numeric value
78    Max { value: f64 },
79    /// Minimum string length
80    MinLength { value: usize },
81    /// Maximum string length
82    MaxLength { value: usize },
83    /// Must be one of the listed values
84    OneOf { values: Vec<String> },
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
88pub struct IndexDefinition {
89    pub name: String,
90    pub columns: Vec<String>,
91    #[serde(default)]
92    pub unique: bool,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
96#[serde(rename_all = "snake_case")]
97pub enum ForeignKeyAction {
98    Cascade,
99    SetNull,
100    Restrict,
101    NoAction,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
105#[serde(rename_all = "lowercase")]
106pub enum ColumnType {
107    Uuid,
108    Text,
109    Integer,
110    BigInteger,
111    Float,
112    Double,
113    Boolean,
114    Timestamp,
115    Date,
116    Jsonb,
117}
118
119impl ColumnType {
120    pub fn to_sql(&self) -> &str {
121        match self {
122            ColumnType::Uuid => "UUID",
123            ColumnType::Text => "TEXT",
124            ColumnType::Integer => "INTEGER",
125            ColumnType::BigInteger => "BIGINT",
126            ColumnType::Float => "REAL",
127            ColumnType::Double => "DOUBLE PRECISION",
128            ColumnType::Boolean => "BOOLEAN",
129            ColumnType::Timestamp => "TIMESTAMPTZ",
130            ColumnType::Date => "DATE",
131            ColumnType::Jsonb => "JSONB",
132        }
133    }
134
135    /// Map to the filter column type for query param parsing
136    pub fn filter_type(&self) -> &str {
137        match self {
138            ColumnType::Uuid => "uuid",
139            ColumnType::Text => "text",
140            ColumnType::Integer | ColumnType::BigInteger => "integer",
141            ColumnType::Float | ColumnType::Double => "double",
142            ColumnType::Boolean => "boolean",
143            ColumnType::Timestamp => "timestamp",
144            ColumnType::Date => "date",
145            ColumnType::Jsonb => "jsonb",
146        }
147    }
148}
149
150/// CRUD lifecycle hooks for a table. Each field names a WASM handler function.
151#[derive(Debug, Clone, Serialize, Deserialize, Default)]
152pub struct TableHooks {
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub before_create: Option<String>,
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub after_create: Option<String>,
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub before_update: Option<String>,
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub after_update: Option<String>,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub before_delete: Option<String>,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub after_delete: Option<String>,
165}
166
167impl TableDefinition {
168    /// Get the primary key column
169    pub fn primary_key(&self) -> Option<&ColumnDefinition> {
170        self.columns.iter().find(|c| c.primary_key)
171    }
172
173    /// Get all non-primary-key, non-auto columns (for INSERT/UPDATE)
174    pub fn writable_columns(&self) -> Vec<&ColumnDefinition> {
175        self.columns
176            .iter()
177            .filter(|c| !c.auto_generate || !c.primary_key)
178            .collect()
179    }
180
181    /// Platform features this table's declaration requires.
182    pub fn derived_features(&self) -> impl Iterator<Item = &'static str> {
183        self.scoped_by
184            .is_some()
185            .then_some(crate::features::SCOPED_PERMISSIONS)
186            .into_iter()
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::manifest::fixtures::sample_manifest;
194
195    #[test]
196    fn test_primary_key() {
197        let manifest = sample_manifest();
198        let table = manifest.get_table("todos").unwrap();
199        let pk = table.primary_key().unwrap();
200        assert_eq!(pk.name, "id");
201        assert!(pk.auto_generate);
202    }
203
204    #[test]
205    fn test_column_type_to_sql() {
206        assert_eq!(ColumnType::Uuid.to_sql(), "UUID");
207        assert_eq!(ColumnType::Text.to_sql(), "TEXT");
208        assert_eq!(ColumnType::Integer.to_sql(), "INTEGER");
209        assert_eq!(ColumnType::BigInteger.to_sql(), "BIGINT");
210        assert_eq!(ColumnType::Boolean.to_sql(), "BOOLEAN");
211        assert_eq!(ColumnType::Timestamp.to_sql(), "TIMESTAMPTZ");
212        assert_eq!(ColumnType::Date.to_sql(), "DATE");
213        assert_eq!(ColumnType::Jsonb.to_sql(), "JSONB");
214    }
215
216    #[test]
217    fn test_fk_not_serialized_when_none() {
218        let col = ColumnDefinition {
219            name: "title".into(),
220            column_type: ColumnType::Text,
221            primary_key: false,
222            nullable: false,
223            auto_generate: false,
224            default_value: None,
225            references: None,
226            on_delete: None,
227            unique: false,
228            validations: vec![],
229        };
230        let json = serde_json::to_string(&col).unwrap();
231        assert!(!json.contains("references"));
232        assert!(!json.contains("on_delete"));
233    }
234
235    #[test]
236    fn test_unique_column_serialization() {
237        let col = ColumnDefinition {
238            name: "email".into(),
239            column_type: ColumnType::Text,
240            primary_key: false,
241            nullable: false,
242            auto_generate: false,
243            default_value: None,
244            references: None,
245            on_delete: None,
246            unique: true,
247            validations: vec![],
248        };
249        let json = serde_json::to_string(&col).unwrap();
250        assert!(json.contains("\"unique\":true"));
251        let parsed: ColumnDefinition = serde_json::from_str(&json).unwrap();
252        assert!(parsed.unique);
253    }
254
255    #[test]
256    fn test_index_serialization() {
257        let table = TableDefinition {
258            name: "users".into(),
259            columns: vec![],
260            indexes: vec![IndexDefinition {
261                name: "idx_users_email".into(),
262                columns: vec!["email".into()],
263                unique: true,
264            }],
265            soft_delete: false,
266            owner_field: None,
267            auth_required: false,
268            permission_area: None,
269            owner_self_service: false,
270            hooks: None,
271            scoped_by: None,
272        };
273        let json = serde_json::to_string(&table).unwrap();
274        assert!(json.contains("idx_users_email"));
275        let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
276        assert_eq!(parsed.indexes.len(), 1);
277        assert!(parsed.indexes[0].unique);
278    }
279
280    #[test]
281    fn test_indexes_not_serialized_when_empty() {
282        let table = TableDefinition {
283            name: "users".into(),
284            columns: vec![],
285            indexes: vec![],
286            soft_delete: false,
287            owner_field: None,
288            auth_required: false,
289            permission_area: None,
290            owner_self_service: false,
291            hooks: None,
292            scoped_by: None,
293        };
294        let json = serde_json::to_string(&table).unwrap();
295        assert!(!json.contains("indexes"));
296    }
297
298    #[test]
299    fn test_owner_field_serialization() {
300        let table = TableDefinition {
301            name: "notes".into(),
302            columns: vec![],
303            indexes: vec![],
304            soft_delete: false,
305            owner_field: Some("user_id".into()),
306            auth_required: false,
307            permission_area: None,
308            owner_self_service: false,
309            hooks: None,
310            scoped_by: None,
311        };
312        let json = serde_json::to_string(&table).unwrap();
313        assert!(json.contains("\"owner_field\":\"user_id\""));
314        let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
315        assert_eq!(parsed.owner_field.as_deref(), Some("user_id"));
316    }
317
318    #[test]
319    fn test_owner_field_not_serialized_when_none() {
320        let table = TableDefinition {
321            name: "notes".into(),
322            columns: vec![],
323            indexes: vec![],
324            soft_delete: false,
325            owner_field: None,
326            auth_required: false,
327            permission_area: None,
328            owner_self_service: false,
329            hooks: None,
330            scoped_by: None,
331        };
332        let json = serde_json::to_string(&table).unwrap();
333        assert!(!json.contains("owner_field"));
334    }
335
336    #[test]
337    fn test_owner_field_defaults_to_none() {
338        let json = r#"{"name":"notes","columns":[]}"#;
339        let table: TableDefinition = serde_json::from_str(json).unwrap();
340        assert!(table.owner_field.is_none());
341    }
342
343    #[test]
344    fn test_auth_required_serialization() {
345        let table = TableDefinition {
346            name: "orders".into(),
347            columns: vec![],
348            indexes: vec![],
349            soft_delete: false,
350            owner_field: None,
351            auth_required: true,
352            permission_area: None,
353            owner_self_service: false,
354            hooks: None,
355            scoped_by: None,
356        };
357        let json = serde_json::to_string(&table).unwrap();
358        assert!(json.contains("\"auth_required\":true"));
359        let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
360        assert!(parsed.auth_required);
361    }
362
363    #[test]
364    fn test_auth_required_defaults_to_false() {
365        let json = r#"{"name":"orders","columns":[]}"#;
366        let table: TableDefinition = serde_json::from_str(json).unwrap();
367        assert!(!table.auth_required);
368    }
369
370    #[test]
371    fn scoped_by_is_omitted_when_absent_and_derives_its_feature() {
372        let table: TableDefinition =
373            serde_json::from_str(r#"{"name":"leads","columns":[],"owner_field":"user_id"}"#)
374                .unwrap();
375        assert!(table.scoped_by.is_none());
376        assert!(!serde_json::to_string(&table).unwrap().contains("scoped_by"));
377        assert_eq!(table.derived_features().count(), 0);
378
379        let scoped = TableDefinition {
380            scoped_by: Some("leads".into()),
381            ..table
382        };
383        let json = serde_json::to_string(&scoped).unwrap();
384        assert!(json.contains(r#""scoped_by":"leads""#), "{json}");
385        assert_eq!(
386            scoped.derived_features().collect::<Vec<_>>(),
387            [crate::features::SCOPED_PERMISSIONS]
388        );
389    }
390}