1use 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}