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