Skip to main content

radixdb_core/
form_descriptor.rs

1//! Language-neutral schema/form projection for administration clients.
2//!
3//! These models contain schema metadata only. They never evaluate expressions
4//! and never contain row values or storage-private state.
5
6use std::collections::{BTreeMap, BTreeSet};
7
8use serde::{Deserialize, Serialize};
9
10use crate::{
11    ConstraintDefinition, DataTypeDescriptor, ForeignKeyActionDescriptor, TableDescriptor,
12};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum EditorKind {
17    Integer,
18    Float,
19    Decimal,
20    Text,
21    Boolean,
22    Timestamp,
23    Date,
24    Json,
25    Uuid,
26    Bytes,
27    Vector,
28    ReferenceSelect,
29    Unsupported,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct ReferenceSelector {
34    pub target_table: String,
35    pub target_column: String,
36    pub on_delete: ForeignKeyActionDescriptor,
37    pub on_update: ForeignKeyActionDescriptor,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct FormFieldDescriptor {
42    pub name: String,
43    pub ordinal: u32,
44    pub data_type: DataTypeDescriptor,
45    pub editor: EditorKind,
46    pub nullable: bool,
47    pub required: bool,
48    pub primary_key: bool,
49    pub unique: bool,
50    pub auto_increment: bool,
51    pub read_only: bool,
52    pub generated: bool,
53    pub default_expression: Option<String>,
54    pub checks: Vec<String>,
55    pub reference: Option<ReferenceSelector>,
56    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
57    pub extensions: BTreeMap<String, serde_json::Value>,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct TableFormDescriptor {
62    pub descriptor: String,
63    pub table: String,
64    pub schema_fingerprint: String,
65    pub fields: Vec<FormFieldDescriptor>,
66    pub table_checks: Vec<String>,
67    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
68    pub extensions: BTreeMap<String, serde_json::Value>,
69}
70
71impl TableFormDescriptor {
72    pub fn from_table(table: &TableDescriptor) -> Self {
73        let mut primary = BTreeSet::new();
74        let mut unique = BTreeSet::new();
75        let mut references = BTreeMap::new();
76        let mut checks: BTreeMap<String, Vec<String>> = BTreeMap::new();
77        let mut table_checks = Vec::new();
78
79        for constraint in &table.constraints {
80            match &constraint.definition {
81                ConstraintDefinition::PrimaryKey { columns } => {
82                    primary.extend(columns.iter().cloned());
83                }
84                ConstraintDefinition::Unique { columns, .. } if columns.len() == 1 => {
85                    unique.insert(columns[0].clone());
86                }
87                ConstraintDefinition::Unique { .. } => {}
88                ConstraintDefinition::ForeignKey {
89                    columns,
90                    referenced_table,
91                    referenced_columns,
92                    on_delete,
93                    on_update,
94                } if columns.len() == 1 && referenced_columns.len() == 1 => {
95                    references.insert(
96                        columns[0].clone(),
97                        ReferenceSelector {
98                            target_table: referenced_table.clone(),
99                            target_column: referenced_columns[0].clone(),
100                            on_delete: *on_delete,
101                            on_update: *on_update,
102                        },
103                    );
104                }
105                ConstraintDefinition::ForeignKey { .. } => {}
106                ConstraintDefinition::Check {
107                    column, expression, ..
108                } => match column {
109                    Some(column) => checks
110                        .entry(column.clone())
111                        .or_default()
112                        .push(expression.clone()),
113                    None => table_checks.push(expression.clone()),
114                },
115            }
116        }
117
118        let fields = table
119            .columns
120            .iter()
121            .map(|column| {
122                let reference = references.remove(&column.name);
123                let generated = extension_flag(&column.extensions, "generated");
124                let read_only = generated || extension_flag(&column.extensions, "read_only");
125                FormFieldDescriptor {
126                    name: column.name.clone(),
127                    ordinal: column.ordinal,
128                    data_type: column.data_type.clone(),
129                    editor: reference
130                        .as_ref()
131                        .map(|_| EditorKind::ReferenceSelect)
132                        .unwrap_or_else(|| editor_for(&column.data_type)),
133                    nullable: column.nullable,
134                    required: !column.nullable
135                        && !column.auto_increment
136                        && column.default_expression.is_none()
137                        && !generated,
138                    primary_key: primary.contains(&column.name),
139                    unique: unique.contains(&column.name) || primary.contains(&column.name),
140                    auto_increment: column.auto_increment,
141                    read_only,
142                    generated,
143                    default_expression: column.default_expression.clone(),
144                    checks: checks.remove(&column.name).unwrap_or_default(),
145                    reference,
146                    extensions: gui_extensions(&column.extensions),
147                }
148            })
149            .collect();
150
151        Self {
152            descriptor: "radixdb.gui.table-form.v1".to_string(),
153            table: table.name.clone(),
154            schema_fingerprint: table.fingerprint.clone(),
155            fields,
156            table_checks,
157            extensions: gui_extensions(&table.extensions),
158        }
159    }
160
161    pub fn to_json(&self) -> Result<String, serde_json::Error> {
162        serde_json::to_string(self)
163    }
164}
165
166fn editor_for(data_type: &DataTypeDescriptor) -> EditorKind {
167    match data_type {
168        DataTypeDescriptor::Integer => EditorKind::Integer,
169        DataTypeDescriptor::Float => EditorKind::Float,
170        DataTypeDescriptor::Text => EditorKind::Text,
171        DataTypeDescriptor::Boolean => EditorKind::Boolean,
172        DataTypeDescriptor::Timestamp => EditorKind::Timestamp,
173        DataTypeDescriptor::Date => EditorKind::Date,
174        DataTypeDescriptor::Json => EditorKind::Json,
175        DataTypeDescriptor::Uuid => EditorKind::Uuid,
176        DataTypeDescriptor::Bytes => EditorKind::Bytes,
177        DataTypeDescriptor::Decimal { .. } => EditorKind::Decimal,
178        DataTypeDescriptor::Vector { .. } => EditorKind::Vector,
179        DataTypeDescriptor::Null => EditorKind::Unsupported,
180    }
181}
182
183fn extension_flag(extensions: &BTreeMap<String, serde_json::Value>, suffix: &str) -> bool {
184    extensions
185        .get(&format!("radixdb.gui.{suffix}"))
186        .or_else(|| extensions.get(&format!("gui.{suffix}")))
187        .and_then(serde_json::Value::as_bool)
188        .unwrap_or(false)
189}
190
191fn gui_extensions(
192    extensions: &BTreeMap<String, serde_json::Value>,
193) -> BTreeMap<String, serde_json::Value> {
194    extensions
195        .iter()
196        .filter(|(name, _)| name.starts_with("gui.") || name.starts_with("radixdb.gui."))
197        .map(|(name, value)| (name.clone(), value.clone()))
198        .collect()
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::{ColumnDescriptor, ConstraintDescriptor};
205
206    #[test]
207    fn form_projection_contains_only_schema_metadata() {
208        let table = TableDescriptor {
209            catalog_id: "catalog".to_string(),
210            name: "documents".to_string(),
211            schema_generation: 1,
212            fingerprint: "fingerprint".to_string(),
213            created_at: "2026-08-21T00:00:00Z".to_string(),
214            updated_at: "2026-08-21T00:00:00Z".to_string(),
215            columns: vec![ColumnDescriptor {
216                ordinal: 0,
217                name: "owner_id".to_string(),
218                data_type: DataTypeDescriptor::Uuid,
219                nullable: false,
220                auto_increment: false,
221                default_expression: None,
222                extensions: BTreeMap::from([
223                    ("radixdb.gui.label".to_string(), serde_json::json!("Owner")),
224                    ("private.note".to_string(), serde_json::json!("hidden")),
225                ]),
226            }],
227            constraints: vec![ConstraintDescriptor {
228                id: 1,
229                name: "fk_documents_owner_id___people".to_string(),
230                definition: ConstraintDefinition::ForeignKey {
231                    columns: vec!["owner_id".to_string()],
232                    referenced_table: "people".to_string(),
233                    referenced_columns: vec!["id".to_string()],
234                    on_delete: ForeignKeyActionDescriptor::Restrict,
235                    on_update: ForeignKeyActionDescriptor::Restrict,
236                },
237            }],
238            indexes: Vec::new(),
239            extensions: BTreeMap::new(),
240        };
241
242        let form = TableFormDescriptor::from_table(&table);
243        assert_eq!(form.fields[0].editor, EditorKind::ReferenceSelect);
244        assert!(form.fields[0].required);
245        assert_eq!(
246            form.fields[0].reference.as_ref().unwrap().target_table,
247            "people"
248        );
249        let json = form.to_json().unwrap();
250        assert!(json.contains("Owner"));
251        assert!(!json.contains("hidden"));
252        assert_eq!(table.form_descriptor(), form);
253    }
254}