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