use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableDefinition {
pub name: String,
pub columns: Vec<ColumnDefinition>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub indexes: Vec<IndexDefinition>,
#[serde(default)]
pub soft_delete: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner_field: Option<String>,
#[serde(default)]
pub auth_required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permission_area: Option<String>,
#[serde(default)]
pub owner_self_service: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hooks: Option<TableHooks>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scoped_by: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ColumnDefinition {
pub name: String,
pub column_type: ColumnType,
#[serde(default)]
pub primary_key: bool,
#[serde(default)]
pub nullable: bool,
#[serde(default)]
pub auto_generate: bool,
pub default_value: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub references: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_delete: Option<ForeignKeyAction>,
#[serde(default)]
pub unique: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub validations: Vec<ValidationRule>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "rule", rename_all = "snake_case")]
pub enum ValidationRule {
Regex { pattern: String },
Min { value: f64 },
Max { value: f64 },
MinLength { value: usize },
MaxLength { value: usize },
OneOf { values: Vec<String> },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct IndexDefinition {
pub name: String,
pub columns: Vec<String>,
#[serde(default)]
pub unique: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ForeignKeyAction {
Cascade,
SetNull,
Restrict,
NoAction,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ColumnType {
Uuid,
Text,
Integer,
BigInteger,
Float,
Double,
Boolean,
Timestamp,
Date,
Jsonb,
}
impl ColumnType {
pub fn to_sql(&self) -> &str {
match self {
ColumnType::Uuid => "UUID",
ColumnType::Text => "TEXT",
ColumnType::Integer => "INTEGER",
ColumnType::BigInteger => "BIGINT",
ColumnType::Float => "REAL",
ColumnType::Double => "DOUBLE PRECISION",
ColumnType::Boolean => "BOOLEAN",
ColumnType::Timestamp => "TIMESTAMPTZ",
ColumnType::Date => "DATE",
ColumnType::Jsonb => "JSONB",
}
}
pub fn filter_type(&self) -> &str {
match self {
ColumnType::Uuid => "uuid",
ColumnType::Text => "text",
ColumnType::Integer | ColumnType::BigInteger => "integer",
ColumnType::Float | ColumnType::Double => "double",
ColumnType::Boolean => "boolean",
ColumnType::Timestamp => "timestamp",
ColumnType::Date => "date",
ColumnType::Jsonb => "jsonb",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TableHooks {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub before_create: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after_create: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub before_update: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after_update: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub before_delete: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after_delete: Option<String>,
}
impl TableDefinition {
pub fn primary_key(&self) -> Option<&ColumnDefinition> {
self.columns.iter().find(|c| c.primary_key)
}
pub fn writable_columns(&self) -> Vec<&ColumnDefinition> {
self.columns
.iter()
.filter(|c| !c.auto_generate || !c.primary_key)
.collect()
}
pub fn derived_features(&self) -> impl Iterator<Item = &'static str> {
self.scoped_by
.is_some()
.then_some(crate::features::SCOPED_PERMISSIONS)
.into_iter()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::fixtures::sample_manifest;
#[test]
fn test_primary_key() {
let manifest = sample_manifest();
let table = manifest.get_table("todos").unwrap();
let pk = table.primary_key().unwrap();
assert_eq!(pk.name, "id");
assert!(pk.auto_generate);
}
#[test]
fn test_column_type_to_sql() {
assert_eq!(ColumnType::Uuid.to_sql(), "UUID");
assert_eq!(ColumnType::Text.to_sql(), "TEXT");
assert_eq!(ColumnType::Integer.to_sql(), "INTEGER");
assert_eq!(ColumnType::BigInteger.to_sql(), "BIGINT");
assert_eq!(ColumnType::Boolean.to_sql(), "BOOLEAN");
assert_eq!(ColumnType::Timestamp.to_sql(), "TIMESTAMPTZ");
assert_eq!(ColumnType::Date.to_sql(), "DATE");
assert_eq!(ColumnType::Jsonb.to_sql(), "JSONB");
}
#[test]
fn test_fk_not_serialized_when_none() {
let col = ColumnDefinition {
name: "title".into(),
column_type: ColumnType::Text,
primary_key: false,
nullable: false,
auto_generate: false,
default_value: None,
references: None,
on_delete: None,
unique: false,
validations: vec![],
};
let json = serde_json::to_string(&col).unwrap();
assert!(!json.contains("references"));
assert!(!json.contains("on_delete"));
}
#[test]
fn test_unique_column_serialization() {
let col = ColumnDefinition {
name: "email".into(),
column_type: ColumnType::Text,
primary_key: false,
nullable: false,
auto_generate: false,
default_value: None,
references: None,
on_delete: None,
unique: true,
validations: vec![],
};
let json = serde_json::to_string(&col).unwrap();
assert!(json.contains("\"unique\":true"));
let parsed: ColumnDefinition = serde_json::from_str(&json).unwrap();
assert!(parsed.unique);
}
#[test]
fn test_index_serialization() {
let table = TableDefinition {
name: "users".into(),
columns: vec![],
indexes: vec![IndexDefinition {
name: "idx_users_email".into(),
columns: vec!["email".into()],
unique: true,
}],
soft_delete: false,
owner_field: None,
auth_required: false,
permission_area: None,
owner_self_service: false,
hooks: None,
scoped_by: None,
};
let json = serde_json::to_string(&table).unwrap();
assert!(json.contains("idx_users_email"));
let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.indexes.len(), 1);
assert!(parsed.indexes[0].unique);
}
#[test]
fn test_indexes_not_serialized_when_empty() {
let table = TableDefinition {
name: "users".into(),
columns: vec![],
indexes: vec![],
soft_delete: false,
owner_field: None,
auth_required: false,
permission_area: None,
owner_self_service: false,
hooks: None,
scoped_by: None,
};
let json = serde_json::to_string(&table).unwrap();
assert!(!json.contains("indexes"));
}
#[test]
fn test_owner_field_serialization() {
let table = TableDefinition {
name: "notes".into(),
columns: vec![],
indexes: vec![],
soft_delete: false,
owner_field: Some("user_id".into()),
auth_required: false,
permission_area: None,
owner_self_service: false,
hooks: None,
scoped_by: None,
};
let json = serde_json::to_string(&table).unwrap();
assert!(json.contains("\"owner_field\":\"user_id\""));
let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.owner_field.as_deref(), Some("user_id"));
}
#[test]
fn test_owner_field_not_serialized_when_none() {
let table = TableDefinition {
name: "notes".into(),
columns: vec![],
indexes: vec![],
soft_delete: false,
owner_field: None,
auth_required: false,
permission_area: None,
owner_self_service: false,
hooks: None,
scoped_by: None,
};
let json = serde_json::to_string(&table).unwrap();
assert!(!json.contains("owner_field"));
}
#[test]
fn test_owner_field_defaults_to_none() {
let json = r#"{"name":"notes","columns":[]}"#;
let table: TableDefinition = serde_json::from_str(json).unwrap();
assert!(table.owner_field.is_none());
}
#[test]
fn test_auth_required_serialization() {
let table = TableDefinition {
name: "orders".into(),
columns: vec![],
indexes: vec![],
soft_delete: false,
owner_field: None,
auth_required: true,
permission_area: None,
owner_self_service: false,
hooks: None,
scoped_by: None,
};
let json = serde_json::to_string(&table).unwrap();
assert!(json.contains("\"auth_required\":true"));
let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
assert!(parsed.auth_required);
}
#[test]
fn test_auth_required_defaults_to_false() {
let json = r#"{"name":"orders","columns":[]}"#;
let table: TableDefinition = serde_json::from_str(json).unwrap();
assert!(!table.auth_required);
}
#[test]
fn scoped_by_is_omitted_when_absent_and_derives_its_feature() {
let table: TableDefinition =
serde_json::from_str(r#"{"name":"leads","columns":[],"owner_field":"user_id"}"#)
.unwrap();
assert!(table.scoped_by.is_none());
assert!(!serde_json::to_string(&table).unwrap().contains("scoped_by"));
assert_eq!(table.derived_features().count(), 0);
let scoped = TableDefinition {
scoped_by: Some("leads".into()),
..table
};
let json = serde_json::to_string(&scoped).unwrap();
assert!(json.contains(r#""scoped_by":"leads""#), "{json}");
assert_eq!(
scoped.derived_features().collect::<Vec<_>>(),
[crate::features::SCOPED_PERMISSIONS]
);
}
}