Skip to main content

radixdb_orm/
codegen.rs

1//! Deterministic Rust facade generation from saved schema descriptors.
2
3use std::fmt::Write;
4
5use crate::*;
6
7#[derive(Debug, thiserror::Error)]
8pub enum CodegenError {
9    #[error(transparent)]
10    Descriptor(#[from] DescriptorError),
11    #[error(
12        "descriptor fingerprint mismatch for '{name}': expected {expected}, computed {actual}"
13    )]
14    Fingerprint {
15        name: String,
16        expected: String,
17        actual: String,
18    },
19    #[error("identifier '{0}' cannot be represented safely in generated Rust")]
20    Identifier(String),
21    #[error("reference '{table}.{column}' must use one source and one target column")]
22    UnsupportedReference { table: String, column: String },
23    #[error("reference target table '{0}' is absent from the descriptor")]
24    MissingReferenceTarget(String),
25    #[error("reference source column '{table}.{column}' is absent from the descriptor")]
26    MissingReferenceSourceColumn { table: String, column: String },
27    #[error("reference target column '{table}.{column}' is absent from the descriptor")]
28    MissingReferenceTargetColumn { table: String, column: String },
29    #[error(
30        "reference target '{table}.{column}' must be a one-column PRIMARY KEY or UNIQUE NOT NULL key"
31    )]
32    UnsupportedReferenceTargetKey { table: String, column: String },
33    #[error(
34        "reference type mismatch: '{source_table}.{source_column}' is {source_type:?}, but '{target_table}.{target_column}' is {target_type:?}"
35    )]
36    ReferenceTypeMismatch {
37        source_table: String,
38        source_column: String,
39        source_type: DataTypeDescriptor,
40        target_table: String,
41        target_column: String,
42        target_type: DataTypeDescriptor,
43    },
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct GeneratedRust {
48    pub source: String,
49    pub descriptor_fingerprint: String,
50}
51
52pub fn generate_rust_database(
53    descriptor: &DescriptorEnvelope<DatabaseDescriptor>,
54) -> Result<GeneratedRust, CodegenError> {
55    if descriptor.descriptor != SCHEMA_DESCRIPTOR_VERSION
56        || descriptor.kind != DescriptorKind::Database
57    {
58        return Err(CodegenError::Descriptor(DescriptorError::KindMismatch {
59            expected: DescriptorKind::Database,
60            actual: descriptor.kind,
61        }));
62    }
63    validate_database_fingerprints(&descriptor.payload)?;
64
65    let mut output = String::new();
66    output.push_str("// @generated by radixdb-orm; DO NOT EDIT.\n");
67    output.push_str("// Source: radixdb.schema.v1 descriptor. No live database was accessed.\n\n");
68    output.push_str("#[allow(unused_imports)]\nuse radixdb_orm::{AsyncOrmGeneratedRecordSession, DataTypeDescriptor, DynamicRecord, Expr, FieldState, FieldValue, GeneratedEntity, GeneratedQuery, GeneratedRecord, GeneratedRecordError, InsertBuilder, OrmGeneratedRecordSession, QueryBuilder, Reference, Relation, RecordMutation, TableDescriptor, TypedColumn, TypedKey, TypedValue, UpdateBuilder, DeleteBuilder, decode_generated_field, decode_generated_reference_field, ensure_schema_fingerprint};\n\n");
69    writeln!(
70        output,
71        "pub const DATABASE_SCHEMA_FINGERPRINT: &str = {:?};\n",
72        descriptor.payload.fingerprint
73    )
74    .unwrap();
75
76    for table in &descriptor.payload.tables {
77        render_table(&mut output, table, &descriptor.payload.tables)?;
78    }
79
80    Ok(GeneratedRust {
81        source: output,
82        descriptor_fingerprint: descriptor.payload.fingerprint.clone(),
83    })
84}
85
86fn validate_database_fingerprints(database: &DatabaseDescriptor) -> Result<(), CodegenError> {
87    for table in &database.tables {
88        let actual = table.computed_fingerprint()?;
89        if table.fingerprint != actual {
90            return Err(CodegenError::Fingerprint {
91                name: table.name.clone(),
92                expected: table.fingerprint.clone(),
93                actual,
94            });
95        }
96    }
97    let actual = database.computed_fingerprint()?;
98    if database.fingerprint != actual {
99        return Err(CodegenError::Fingerprint {
100            name: "database".to_string(),
101            expected: database.fingerprint.clone(),
102            actual,
103        });
104    }
105    Ok(())
106}
107
108fn render_table(
109    output: &mut String,
110    table: &TableDescriptor,
111    tables: &[TableDescriptor],
112) -> Result<(), CodegenError> {
113    validate_reference_shapes(table, tables)?;
114    let entity = rust_type_name(&table.name)?;
115    let record = format!("{entity}Record");
116    writeln!(
117        output,
118        "#[derive(Debug, Clone, PartialEq)]\npub struct {record} {{"
119    )
120    .unwrap();
121    for column in &table.columns {
122        let field_type = match column_reference(table, &column.name) {
123            Some((target_table, _)) => rust_type_name(target_table)?,
124            None => rust_type(&column.data_type).to_string(),
125        };
126        writeln!(
127            output,
128            "    pub {}: FieldState<{}>,",
129            rust_field_name(&column.name)?,
130            match column_reference(table, &column.name) {
131                Some(_) => format!("Reference<{field_type}>"),
132                None => field_type,
133            }
134        )
135        .unwrap();
136    }
137    output.push_str("}\n\n");
138
139    writeln!(output, "impl Default for {record} {{").unwrap();
140    output.push_str("    fn default() -> Self {\n        Self {\n");
141    for column in &table.columns {
142        writeln!(
143            output,
144            "            {}: FieldState::typed({}),",
145            rust_field_name(&column.name)?,
146            rust_data_type(&column.data_type)
147        )
148        .unwrap();
149    }
150    output.push_str("        }\n    }\n}\n\n");
151
152    writeln!(
153        output,
154        "#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub struct {entity};"
155    )
156    .unwrap();
157    writeln!(output, "impl GeneratedEntity for {entity} {{").unwrap();
158    writeln!(output, "    type Record = {record};").unwrap();
159    writeln!(output, "    const TABLE: &'static str = {:?};", table.name).unwrap();
160    writeln!(
161        output,
162        "    const CATALOG_ID: &'static str = {:?};",
163        table.catalog_id
164    )
165    .unwrap();
166    writeln!(
167        output,
168        "    const SCHEMA_FINGERPRINT: &'static str = {:?};",
169        table.fingerprint
170    )
171    .unwrap();
172    output.push_str("}\n\n");
173
174    writeln!(output, "impl {entity} {{").unwrap();
175    for column in &table.columns {
176        let column_type = match column_reference(table, &column.name) {
177            Some((target_table, _)) => format!("Reference<{}>", rust_type_name(target_table)?),
178            None => rust_type(&column.data_type).to_string(),
179        };
180        writeln!(
181            output,
182            "    pub const {}: TypedColumn<{}, Self> = TypedColumn::new({:?}, {:?}, {}, {});",
183            rust_const_name(&column.name)?,
184            column_type,
185            table.name,
186            column.name,
187            rust_data_type(&column.data_type),
188            column.nullable
189        )
190        .unwrap();
191    }
192    writeln!(
193        output,
194        "    #[allow(clippy::new_ret_no_self)]\n    pub fn new() -> {record} {{ {record}::default() }}"
195    )
196    .unwrap();
197    writeln!(output, "    pub fn table() -> Relation {{ Relation::Table {{ name: {:?}.to_string(), alias: None }} }}", table.name).unwrap();
198    output.push_str(
199        "    pub fn query() -> QueryBuilder { QueryBuilder::from_relation(Self::table()) }\n",
200    );
201    writeln!(
202        output,
203        "    pub fn insert() -> InsertBuilder {{ InsertBuilder::new({:?}) }}",
204        table.name
205    )
206    .unwrap();
207    writeln!(
208        output,
209        "    pub fn upsert() -> InsertBuilder {{ InsertBuilder::new({:?}) }}",
210        table.name
211    )
212    .unwrap();
213    writeln!(
214        output,
215        "    pub fn update() -> UpdateBuilder {{ UpdateBuilder::new({:?}) }}",
216        table.name
217    )
218    .unwrap();
219    writeln!(
220        output,
221        "    pub fn delete() -> DeleteBuilder {{ DeleteBuilder::new({:?}) }}",
222        table.name
223    )
224    .unwrap();
225    if let Some(primary_key) = primary_reference_key(table) {
226        let column = table
227            .columns
228            .iter()
229            .find(|candidate| candidate.name == primary_key)
230            .expect("constraint column exists");
231        writeln!(output, "    pub fn get(key: {}) -> GeneratedQuery<{record}> {{ GeneratedQuery::new(Self::query().select([Expr::star()]).filter(Self::{}.eq(Expr::value({})))) }}", rust_type(&column.data_type), rust_const_name(primary_key)?, owned_typed_value_expression("key", &column.data_type)).unwrap();
232    }
233    for (key, is_primary) in reference_keys(table) {
234        let column = table
235            .columns
236            .iter()
237            .find(|candidate| candidate.name == key)
238            .expect("constraint column exists");
239        let method = if is_primary {
240            "reference".to_string()
241        } else {
242            format!("reference_by_{}", rust_field_name(key)?)
243        };
244        let encoder = format!("encode_{}_key", rust_field_name(key)?);
245        writeln!(
246            output,
247            "    fn {encoder}(key: {}) -> TypedValue {{ {} }}",
248            rust_type(&column.data_type),
249            owned_typed_value_expression("key", &column.data_type)
250        )
251        .unwrap();
252        writeln!(
253            output,
254            "    pub fn {method}(key: {}) -> Reference<Self> {{ {}Keys::{}.reference(key) }}",
255            rust_type(&column.data_type),
256            entity,
257            rust_const_name(key)?
258        )
259        .unwrap();
260    }
261    output.push_str("}\n\n");
262
263    if !reference_keys(table).is_empty() {
264        writeln!(
265            output,
266            "#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub struct {entity}Keys;"
267        )
268        .unwrap();
269        writeln!(output, "impl {entity}Keys {{").unwrap();
270        for (key, is_primary) in reference_keys(table) {
271            let column = table
272                .columns
273                .iter()
274                .find(|candidate| candidate.name == key)
275                .expect("constraint column exists");
276            writeln!(
277                output,
278                "    pub const {}: TypedKey<{entity}, {}> = TypedKey::new({entity}::{}, {}, {entity}::encode_{}_key);",
279                rust_const_name(key)?,
280                rust_type(&column.data_type),
281                rust_const_name(key)?,
282                is_primary,
283                rust_field_name(key)?,
284            )
285            .unwrap();
286        }
287        output.push_str("}\n\n");
288    }
289
290    writeln!(output, "impl {record} {{").unwrap();
291    writeln!(output, "    pub fn to_dynamic(&self, descriptor: &TableDescriptor) -> Result<DynamicRecord, GeneratedRecordError> {{").unwrap();
292    writeln!(output, "        ensure_schema_fingerprint({entity}::SCHEMA_FINGERPRINT, &descriptor.fingerprint)?;").unwrap();
293    output.push_str("        let mut record = DynamicRecord::new(descriptor.clone());\n");
294    for column in &table.columns {
295        let field = rust_field_name(&column.name)?;
296        let typed_value = match column_reference(table, &column.name) {
297            Some(_) => "value.key().clone()".to_string(),
298            None => typed_value_expression("value", &column.data_type),
299        };
300        writeln!(output, "        match self.{field}.value() {{").unwrap();
301        output.push_str("            FieldValue::Omitted => {}\n");
302        writeln!(output, "            FieldValue::Null {{ data_type }} => if self.{field}.is_dirty() {{ record.set_null({:?})? }} else {{ record.hydrate({:?}, TypedValue::Null(data_type.clone()))? }},", column.name, column.name).unwrap();
303        writeln!(output, "            FieldValue::Value {{ value }} => if self.{field}.is_dirty() {{ record.set({:?}, {typed_value})? }} else {{ record.hydrate({:?}, {typed_value})? }},", column.name, column.name).unwrap();
304        output.push_str("        }\n");
305    }
306    output.push_str("        Ok(record)\n    }\n");
307    writeln!(output, "    pub fn insert<S: OrmGeneratedRecordSession>(&mut self, session: S) -> Result<(), S::Error> {{ session.mutate_generated_record(self, RecordMutation::Insert) }}").unwrap();
308    writeln!(output, "    pub fn save<S: OrmGeneratedRecordSession>(&mut self, session: S) -> Result<(), S::Error> {{ session.mutate_generated_record(self, RecordMutation::Save) }}").unwrap();
309    writeln!(output, "    pub fn update<S: OrmGeneratedRecordSession>(&mut self, session: S) -> Result<(), S::Error> {{ session.mutate_generated_record(self, RecordMutation::Update) }}").unwrap();
310    writeln!(output, "    pub fn delete<S: OrmGeneratedRecordSession>(&mut self, session: S) -> Result<(), S::Error> {{ session.mutate_generated_record(self, RecordMutation::Delete) }}").unwrap();
311    writeln!(output, "    pub async fn insert_async<S: AsyncOrmGeneratedRecordSession>(&mut self, session: &mut S) -> Result<(), S::Error> {{ session.mutate_generated_record_async(self, RecordMutation::Insert).await }}").unwrap();
312    writeln!(output, "    pub async fn save_async<S: AsyncOrmGeneratedRecordSession>(&mut self, session: &mut S) -> Result<(), S::Error> {{ session.mutate_generated_record_async(self, RecordMutation::Save).await }}").unwrap();
313    writeln!(output, "    pub async fn update_async<S: AsyncOrmGeneratedRecordSession>(&mut self, session: &mut S) -> Result<(), S::Error> {{ session.mutate_generated_record_async(self, RecordMutation::Update).await }}").unwrap();
314    writeln!(output, "    pub async fn delete_async<S: AsyncOrmGeneratedRecordSession>(&mut self, session: &mut S) -> Result<(), S::Error> {{ session.mutate_generated_record_async(self, RecordMutation::Delete).await }}").unwrap();
315    output.push_str("}\n\n");
316
317    writeln!(output, "impl GeneratedRecord for {record} {{").unwrap();
318    writeln!(output, "    type Entity = {entity};").unwrap();
319    writeln!(output, "    fn to_dynamic(&self, descriptor: &TableDescriptor) -> Result<DynamicRecord, GeneratedRecordError> {{ {record}::to_dynamic(self, descriptor) }}").unwrap();
320    output.push_str("    fn apply_dynamic(&mut self, record: &DynamicRecord) -> Result<(), GeneratedRecordError> {\n");
321    writeln!(output, "        ensure_schema_fingerprint({entity}::SCHEMA_FINGERPRINT, &record.descriptor().fingerprint)?;").unwrap();
322    for column in &table.columns {
323        let field = rust_field_name(&column.name)?;
324        if let Some((target_table, target_column)) = column_reference(table, &column.name) {
325            let target_entity = rust_type_name(target_table)?;
326            writeln!(output, "        self.{field}.hydrate(decode_generated_reference_field::<{target_entity}>({:?}, record.field({:?})?, &{}, {:?}, {:?})?);", column.name, column.name, rust_data_type(&column.data_type), target_table, target_column).unwrap();
327        } else {
328            writeln!(output, "        self.{field}.hydrate(decode_generated_field({:?}, record.field({:?})?, &{})?);", column.name, column.name, rust_data_type(&column.data_type)).unwrap();
329        }
330    }
331    output.push_str("        Ok(())\n    }\n}\n\n");
332    Ok(())
333}
334
335fn validate_reference_shapes(
336    table: &TableDescriptor,
337    tables: &[TableDescriptor],
338) -> Result<(), CodegenError> {
339    for constraint in &table.constraints {
340        let ConstraintDefinition::ForeignKey {
341            columns,
342            referenced_table,
343            referenced_columns,
344            ..
345        } = &constraint.definition
346        else {
347            continue;
348        };
349        if columns.len() != 1 || referenced_columns.len() != 1 {
350            return Err(CodegenError::UnsupportedReference {
351                table: table.name.clone(),
352                column: columns.join(","),
353            });
354        }
355        if !tables.iter().any(|table| table.name == *referenced_table) {
356            return Err(CodegenError::MissingReferenceTarget(
357                referenced_table.clone(),
358            ));
359        }
360        let source_column = table
361            .columns
362            .iter()
363            .find(|column| column.name == columns[0])
364            .ok_or_else(|| CodegenError::MissingReferenceSourceColumn {
365                table: table.name.clone(),
366                column: columns[0].clone(),
367            })?;
368        let target_table = tables
369            .iter()
370            .find(|table| table.name == *referenced_table)
371            .expect("target table presence checked");
372        let target_column = target_table
373            .columns
374            .iter()
375            .find(|column| column.name == referenced_columns[0])
376            .ok_or_else(|| CodegenError::MissingReferenceTargetColumn {
377                table: referenced_table.clone(),
378                column: referenced_columns[0].clone(),
379            })?;
380        if !reference_keys(target_table)
381            .iter()
382            .any(|(column, _)| *column == target_column.name)
383        {
384            return Err(CodegenError::UnsupportedReferenceTargetKey {
385                table: referenced_table.clone(),
386                column: referenced_columns[0].clone(),
387            });
388        }
389        if source_column.data_type != target_column.data_type {
390            return Err(CodegenError::ReferenceTypeMismatch {
391                source_table: table.name.clone(),
392                source_column: source_column.name.clone(),
393                source_type: source_column.data_type.clone(),
394                target_table: target_table.name.clone(),
395                target_column: target_column.name.clone(),
396                target_type: target_column.data_type.clone(),
397            });
398        }
399    }
400    Ok(())
401}
402
403fn column_reference<'a>(table: &'a TableDescriptor, column: &str) -> Option<(&'a str, &'a str)> {
404    table.constraints.iter().find_map(|constraint| {
405        let ConstraintDefinition::ForeignKey {
406            columns,
407            referenced_table,
408            referenced_columns,
409            ..
410        } = &constraint.definition
411        else {
412            return None;
413        };
414        (columns.as_slice() == [column] && referenced_columns.len() == 1)
415            .then(|| (referenced_table.as_str(), referenced_columns[0].as_str()))
416    })
417}
418
419fn primary_reference_key(table: &TableDescriptor) -> Option<&str> {
420    table.constraints.iter().find_map(|constraint| {
421        let ConstraintDefinition::PrimaryKey { columns } = &constraint.definition else {
422            return None;
423        };
424        (columns.len() == 1).then(|| columns[0].as_str())
425    })
426}
427
428fn reference_keys(table: &TableDescriptor) -> Vec<(&str, bool)> {
429    let mut keys = Vec::new();
430    for constraint in &table.constraints {
431        let (columns, is_primary) = match &constraint.definition {
432            ConstraintDefinition::PrimaryKey { columns } => (columns, true),
433            ConstraintDefinition::Unique { columns, .. } => (columns, false),
434            _ => continue,
435        };
436        if columns.len() == 1 {
437            if let Some(column) = table
438                .columns
439                .iter()
440                .find(|column| column.name == columns[0] && !column.nullable)
441            {
442                if !keys.iter().any(|(name, _)| *name == column.name) {
443                    keys.push((column.name.as_str(), is_primary));
444                }
445            }
446        }
447    }
448    keys
449}
450
451fn rust_type(data_type: &DataTypeDescriptor) -> &'static str {
452    match data_type {
453        DataTypeDescriptor::Integer => "i64",
454        DataTypeDescriptor::Float | DataTypeDescriptor::DoublePrecision => "f64",
455        DataTypeDescriptor::Boolean => "bool",
456        DataTypeDescriptor::Json => "serde_json::Value",
457        DataTypeDescriptor::Bytes => "String",
458        DataTypeDescriptor::Vector { .. } => "Vec<f32>",
459        DataTypeDescriptor::Null
460        | DataTypeDescriptor::Text { .. }
461        | DataTypeDescriptor::Timestamp
462        | DataTypeDescriptor::CivilTimestamp
463        | DataTypeDescriptor::Time
464        | DataTypeDescriptor::Date
465        | DataTypeDescriptor::Uuid
466        | DataTypeDescriptor::Decimal { .. } => "String",
467    }
468}
469
470fn rust_data_type(data_type: &DataTypeDescriptor) -> String {
471    match data_type {
472        DataTypeDescriptor::Null => "DataTypeDescriptor::Null".to_string(),
473        DataTypeDescriptor::Integer => "DataTypeDescriptor::Integer".to_string(),
474        DataTypeDescriptor::Float => "DataTypeDescriptor::Float".to_string(),
475        DataTypeDescriptor::DoublePrecision => "DataTypeDescriptor::DoublePrecision".to_string(),
476        DataTypeDescriptor::Text { max_chars } => {
477            format!("DataTypeDescriptor::Text {{ max_chars: {max_chars:?} }}")
478        }
479        DataTypeDescriptor::Boolean => "DataTypeDescriptor::Boolean".to_string(),
480        DataTypeDescriptor::Timestamp => "DataTypeDescriptor::Timestamp".to_string(),
481        DataTypeDescriptor::CivilTimestamp => "DataTypeDescriptor::CivilTimestamp".to_string(),
482        DataTypeDescriptor::Time => "DataTypeDescriptor::Time".to_string(),
483        DataTypeDescriptor::Date => "DataTypeDescriptor::Date".to_string(),
484        DataTypeDescriptor::Json => "DataTypeDescriptor::Json".to_string(),
485        DataTypeDescriptor::Uuid => "DataTypeDescriptor::Uuid".to_string(),
486        DataTypeDescriptor::Bytes => "DataTypeDescriptor::Bytes".to_string(),
487        DataTypeDescriptor::Decimal { precision, scale } => {
488            format!("DataTypeDescriptor::Decimal {{ precision: {precision:?}, scale: {scale:?} }}")
489        }
490        DataTypeDescriptor::Vector { dimensions } => {
491            format!("DataTypeDescriptor::Vector {{ dimensions: {dimensions} }}")
492        }
493    }
494}
495
496fn typed_value_expression(value: &str, data_type: &DataTypeDescriptor) -> String {
497    match data_type {
498        DataTypeDescriptor::Integer => format!("TypedValue::Integer(*{value})"),
499        DataTypeDescriptor::Float | DataTypeDescriptor::DoublePrecision => {
500            format!("TypedValue::Float((*{value}).into())")
501        }
502        DataTypeDescriptor::Text { .. } => format!("TypedValue::Text({value}.clone())"),
503        DataTypeDescriptor::Boolean => format!("TypedValue::Boolean(*{value})"),
504        DataTypeDescriptor::Timestamp => format!("TypedValue::Timestamp({value}.clone())"),
505        DataTypeDescriptor::CivilTimestamp => {
506            format!("TypedValue::CivilTimestamp({value}.clone())")
507        }
508        DataTypeDescriptor::Time => format!("TypedValue::Time({value}.clone())"),
509        DataTypeDescriptor::Date => format!("TypedValue::Date({value}.clone())"),
510        DataTypeDescriptor::Json => format!("TypedValue::Json({value}.clone())"),
511        DataTypeDescriptor::Uuid => format!("TypedValue::Uuid({value}.clone())"),
512        DataTypeDescriptor::Bytes => format!("TypedValue::Bytes({value}.clone())"),
513        DataTypeDescriptor::Decimal { .. } => format!("TypedValue::Decimal({value}.clone())"),
514        DataTypeDescriptor::Vector { .. } => format!("TypedValue::Vector({value}.clone())"),
515        DataTypeDescriptor::Null => "TypedValue::Null(DataTypeDescriptor::Null)".to_string(),
516    }
517}
518
519fn owned_typed_value_expression(value: &str, data_type: &DataTypeDescriptor) -> String {
520    match data_type {
521        DataTypeDescriptor::Integer => format!("TypedValue::Integer({value})"),
522        DataTypeDescriptor::Float | DataTypeDescriptor::DoublePrecision => {
523            format!("TypedValue::Float({value}.into())")
524        }
525        DataTypeDescriptor::Text { .. } => format!("TypedValue::Text({value})"),
526        DataTypeDescriptor::Boolean => format!("TypedValue::Boolean({value})"),
527        DataTypeDescriptor::Timestamp => format!("TypedValue::Timestamp({value})"),
528        DataTypeDescriptor::CivilTimestamp => {
529            format!("TypedValue::CivilTimestamp({value})")
530        }
531        DataTypeDescriptor::Time => format!("TypedValue::Time({value})"),
532        DataTypeDescriptor::Date => format!("TypedValue::Date({value})"),
533        DataTypeDescriptor::Json => format!("TypedValue::Json({value})"),
534        DataTypeDescriptor::Uuid => format!("TypedValue::Uuid({value})"),
535        DataTypeDescriptor::Bytes => format!("TypedValue::Bytes({value})"),
536        DataTypeDescriptor::Decimal { .. } => format!("TypedValue::Decimal({value})"),
537        DataTypeDescriptor::Vector { .. } => format!("TypedValue::Vector({value})"),
538        DataTypeDescriptor::Null => "TypedValue::Null(DataTypeDescriptor::Null)".to_string(),
539    }
540}
541
542fn rust_type_name(identifier: &str) -> Result<String, CodegenError> {
543    let parts = identifier_parts(identifier)?;
544    Ok(parts.into_iter().map(capitalize).collect())
545}
546
547fn rust_const_name(identifier: &str) -> Result<String, CodegenError> {
548    Ok(identifier_parts(identifier)?.join("_").to_ascii_uppercase())
549}
550
551fn rust_field_name(identifier: &str) -> Result<String, CodegenError> {
552    let mut name = identifier_parts(identifier)?.join("_").to_ascii_lowercase();
553    if is_rust_keyword(&name) {
554        name.insert_str(0, "r#");
555    }
556    Ok(name)
557}
558
559fn identifier_parts(identifier: &str) -> Result<Vec<&str>, CodegenError> {
560    let parts: Vec<_> = identifier
561        .split(|character: char| !character.is_ascii_alphanumeric())
562        .filter(|part| !part.is_empty())
563        .collect();
564    if parts.is_empty() || parts[0].as_bytes().first().is_some_and(u8::is_ascii_digit) {
565        return Err(CodegenError::Identifier(identifier.to_string()));
566    }
567    Ok(parts)
568}
569
570fn capitalize(part: &str) -> String {
571    let mut chars = part.chars();
572    match chars.next() {
573        Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
574        None => String::new(),
575    }
576}
577
578fn is_rust_keyword(value: &str) -> bool {
579    matches!(
580        value,
581        "as" | "break"
582            | "const"
583            | "continue"
584            | "crate"
585            | "else"
586            | "enum"
587            | "extern"
588            | "false"
589            | "fn"
590            | "for"
591            | "if"
592            | "impl"
593            | "in"
594            | "let"
595            | "loop"
596            | "match"
597            | "mod"
598            | "move"
599            | "mut"
600            | "pub"
601            | "ref"
602            | "return"
603            | "self"
604            | "Self"
605            | "static"
606            | "struct"
607            | "super"
608            | "trait"
609            | "true"
610            | "type"
611            | "unsafe"
612            | "use"
613            | "where"
614            | "while"
615            | "async"
616            | "await"
617            | "dyn"
618    )
619}
620
621#[cfg(test)]
622mod tests {
623    use std::collections::BTreeMap;
624    use std::fs;
625    use std::process::Command;
626
627    use super::*;
628
629    #[test]
630    fn codegen_is_deterministic_offline_and_fingerprint_bound() {
631        let mut table = TableDescriptor {
632            catalog_id: "018f2b34-7a10-7cc2-8f3a-9d4b5c6d7e01".to_string(),
633            name: "people".to_string(),
634            schema_generation: 1,
635            fingerprint: String::new(),
636            created_at: "x".to_string(),
637            updated_at: "x".to_string(),
638            columns: vec![ColumnDescriptor {
639                ordinal: 0,
640                name: "id".to_string(),
641                data_type: DataTypeDescriptor::Integer,
642                nullable: false,
643                auto_increment: false,
644                default_expression: None,
645                extensions: BTreeMap::new(),
646            }],
647            constraints: vec![ConstraintDescriptor {
648                id: 1,
649                name: "pk_people".to_string(),
650                definition: ConstraintDefinition::PrimaryKey {
651                    columns: vec!["id".to_string()],
652                },
653            }],
654            indexes: Vec::new(),
655            extensions: BTreeMap::new(),
656        };
657        table.refresh_fingerprint().unwrap();
658        let mut database = DatabaseDescriptor {
659            schema_generation: 1,
660            fingerprint: String::new(),
661            tables: vec![table],
662            views: Vec::new(),
663            procedures: Vec::new(),
664            extensions: BTreeMap::new(),
665        };
666        database.refresh_fingerprint().unwrap();
667        let envelope = DescriptorEnvelope::new(DescriptorKind::Database, database);
668        let first = generate_rust_database(&envelope).unwrap();
669        let second = generate_rust_database(&envelope).unwrap();
670        assert_eq!(first, second);
671        assert!(first
672            .source
673            .contains("pub const ID: TypedColumn<i64, Self>"));
674        assert!(first.source.contains("pub const ID: TypedKey<People, i64>"));
675        assert!(first.source.contains("pub fn reference(key: i64)"));
676
677        let mut stale = envelope.clone();
678        stale.payload.tables[0].columns[0].nullable = true;
679        assert!(matches!(
680            generate_rust_database(&stale),
681            Err(CodegenError::Fingerprint { .. })
682        ));
683    }
684
685    #[test]
686    fn codegen_rejects_unusable_or_type_mismatched_reference_targets() {
687        fn descriptor(
688            target_type: DataTypeDescriptor,
689            target_nullable: bool,
690            target_constraint: ConstraintDefinition,
691            target_column: &str,
692        ) -> DescriptorEnvelope<DatabaseDescriptor> {
693            let mut target = TableDescriptor {
694                catalog_id: "target-id".to_string(),
695                name: "targets".to_string(),
696                schema_generation: 1,
697                fingerprint: String::new(),
698                created_at: "x".to_string(),
699                updated_at: "x".to_string(),
700                columns: vec![ColumnDescriptor {
701                    ordinal: 0,
702                    name: "id".to_string(),
703                    data_type: target_type,
704                    nullable: target_nullable,
705                    auto_increment: false,
706                    default_expression: None,
707                    extensions: BTreeMap::new(),
708                }],
709                constraints: vec![ConstraintDescriptor {
710                    id: 1,
711                    name: "target_key".to_string(),
712                    definition: target_constraint,
713                }],
714                indexes: Vec::new(),
715                extensions: BTreeMap::new(),
716            };
717            target.refresh_fingerprint().unwrap();
718            let mut source = TableDescriptor {
719                catalog_id: "source-id".to_string(),
720                name: "sources".to_string(),
721                schema_generation: 1,
722                fingerprint: String::new(),
723                created_at: "x".to_string(),
724                updated_at: "x".to_string(),
725                columns: vec![ColumnDescriptor {
726                    ordinal: 0,
727                    name: "target_id".to_string(),
728                    data_type: DataTypeDescriptor::Integer,
729                    nullable: false,
730                    auto_increment: false,
731                    default_expression: None,
732                    extensions: BTreeMap::new(),
733                }],
734                constraints: vec![ConstraintDescriptor {
735                    id: 1,
736                    name: "fk_sources_target_id___targets".to_string(),
737                    definition: ConstraintDefinition::ForeignKey {
738                        columns: vec!["target_id".to_string()],
739                        referenced_table: "targets".to_string(),
740                        referenced_columns: vec![target_column.to_string()],
741                        on_delete: ForeignKeyActionDescriptor::Restrict,
742                        on_update: ForeignKeyActionDescriptor::Restrict,
743                    },
744                }],
745                indexes: Vec::new(),
746                extensions: BTreeMap::new(),
747            };
748            source.refresh_fingerprint().unwrap();
749            let mut database = DatabaseDescriptor {
750                schema_generation: 1,
751                fingerprint: String::new(),
752                tables: vec![target, source],
753                views: Vec::new(),
754                procedures: Vec::new(),
755                extensions: BTreeMap::new(),
756            };
757            database.refresh_fingerprint().unwrap();
758            DescriptorEnvelope::new(DescriptorKind::Database, database)
759        }
760
761        let missing = descriptor(
762            DataTypeDescriptor::Integer,
763            false,
764            ConstraintDefinition::PrimaryKey {
765                columns: vec!["id".to_string()],
766            },
767            "absent",
768        );
769        assert!(matches!(
770            generate_rust_database(&missing),
771            Err(CodegenError::MissingReferenceTargetColumn { .. })
772        ));
773
774        let ordinary = descriptor(
775            DataTypeDescriptor::Integer,
776            false,
777            ConstraintDefinition::Check {
778                column: Some("id".to_string()),
779                expression: "id > 0".to_string(),
780                ordinal: 1,
781            },
782            "id",
783        );
784        assert!(matches!(
785            generate_rust_database(&ordinary),
786            Err(CodegenError::UnsupportedReferenceTargetKey { .. })
787        ));
788
789        let nullable_unique = descriptor(
790            DataTypeDescriptor::Integer,
791            true,
792            ConstraintDefinition::Unique {
793                columns: vec!["id".to_string()],
794                owned_index: "uq_targets_id".to_string(),
795            },
796            "id",
797        );
798        assert!(matches!(
799            generate_rust_database(&nullable_unique),
800            Err(CodegenError::UnsupportedReferenceTargetKey { .. })
801        ));
802
803        let wrong_type = descriptor(
804            DataTypeDescriptor::Text { max_chars: None },
805            false,
806            ConstraintDefinition::PrimaryKey {
807                columns: vec!["id".to_string()],
808            },
809            "id",
810        );
811        assert!(matches!(
812            generate_rust_database(&wrong_type),
813            Err(CodegenError::ReferenceTypeMismatch { .. })
814        ));
815    }
816
817    #[test]
818    fn generated_source_compiles_as_an_independent_offline_crate() {
819        let mut table = TableDescriptor {
820            catalog_id: "018f2b34-7a10-7cc2-8f3a-9d4b5c6d7e01".to_string(),
821            name: "people".to_string(),
822            schema_generation: 1,
823            fingerprint: String::new(),
824            created_at: "x".to_string(),
825            updated_at: "x".to_string(),
826            columns: vec![
827                ColumnDescriptor {
828                    ordinal: 0,
829                    name: "id".to_string(),
830                    data_type: DataTypeDescriptor::Integer,
831                    nullable: false,
832                    auto_increment: false,
833                    default_expression: None,
834                    extensions: BTreeMap::new(),
835                },
836                ColumnDescriptor {
837                    ordinal: 1,
838                    name: "metadata".to_string(),
839                    data_type: DataTypeDescriptor::Json,
840                    nullable: true,
841                    auto_increment: false,
842                    default_expression: None,
843                    extensions: BTreeMap::new(),
844                },
845                ColumnDescriptor {
846                    ordinal: 2,
847                    name: "fio_id".to_string(),
848                    data_type: DataTypeDescriptor::Integer,
849                    nullable: true,
850                    auto_increment: false,
851                    default_expression: None,
852                    extensions: BTreeMap::new(),
853                },
854                ColumnDescriptor {
855                    ordinal: 3,
856                    name: "measurement".to_string(),
857                    data_type: DataTypeDescriptor::DoublePrecision,
858                    nullable: false,
859                    auto_increment: false,
860                    default_expression: None,
861                    extensions: BTreeMap::new(),
862                },
863            ],
864            constraints: vec![
865                ConstraintDescriptor {
866                    id: 1,
867                    name: "pk_people".to_string(),
868                    definition: ConstraintDefinition::PrimaryKey {
869                        columns: vec!["id".to_string()],
870                    },
871                },
872                ConstraintDescriptor {
873                    id: 2,
874                    name: "fk_people_fio_id___fio".to_string(),
875                    definition: ConstraintDefinition::ForeignKey {
876                        columns: vec!["fio_id".to_string()],
877                        referenced_table: "fio".to_string(),
878                        referenced_columns: vec!["id".to_string()],
879                        on_delete: ForeignKeyActionDescriptor::Restrict,
880                        on_update: ForeignKeyActionDescriptor::Restrict,
881                    },
882                },
883            ],
884            indexes: Vec::new(),
885            extensions: BTreeMap::new(),
886        };
887        table.refresh_fingerprint().unwrap();
888        let mut fio = TableDescriptor {
889            catalog_id: "018f2b34-7a10-7cc2-8f3a-9d4b5c6d7e02".to_string(),
890            name: "fio".to_string(),
891            schema_generation: 1,
892            fingerprint: String::new(),
893            created_at: "x".to_string(),
894            updated_at: "x".to_string(),
895            columns: vec![ColumnDescriptor {
896                ordinal: 0,
897                name: "id".to_string(),
898                data_type: DataTypeDescriptor::Integer,
899                nullable: false,
900                auto_increment: false,
901                default_expression: None,
902                extensions: BTreeMap::new(),
903            }],
904            constraints: vec![ConstraintDescriptor {
905                id: 1,
906                name: "pk_fio".to_string(),
907                definition: ConstraintDefinition::PrimaryKey {
908                    columns: vec!["id".to_string()],
909                },
910            }],
911            indexes: Vec::new(),
912            extensions: BTreeMap::new(),
913        };
914        fio.refresh_fingerprint().unwrap();
915        let mut database = DatabaseDescriptor {
916            schema_generation: 1,
917            fingerprint: String::new(),
918            tables: vec![fio, table],
919            views: Vec::new(),
920            procedures: Vec::new(),
921            extensions: BTreeMap::new(),
922        };
923        database.refresh_fingerprint().unwrap();
924        let mut generated =
925            generate_rust_database(&DescriptorEnvelope::new(DescriptorKind::Database, database))
926                .unwrap();
927        generated.source.push_str(
928            "\n#[allow(dead_code)]\nfn compile_generated_insert<S: radixdb_orm::OrmGeneratedRecordSession>(mut record: PeopleRecord, session: S) -> Result<(), S::Error> { record.insert(session) }\n",
929        );
930        assert!(generated.source.contains("FieldState<Reference<Fio>>"));
931        generated.source.push_str(
932            "\n#[allow(dead_code)]\nfn compile_generated_get<S>(session: S) -> Result<Option<PeopleRecord>, S::Error> where S: radixdb_orm::OrmGeneratedQuerySession, S::Error: From<radixdb_orm::RecordError> + From<radixdb_orm::BuilderError> { People::get(1).optional(session) }\n",
933        );
934        generated.source.push_str(
935            "\n#[allow(dead_code)]\nfn compile_generated_null(mut record: PeopleRecord) { record.metadata.set_null(); record.metadata.unset(); }\n",
936        );
937        generated.source.push_str(
938            "\n#[allow(dead_code)]\nasync fn compile_generated_async<S>(mut record: PeopleRecord, session: &mut S) -> Result<Option<PeopleRecord>, <S as radixdb_orm::AsyncOrmGeneratedRecordSession>::Error> where S: radixdb_orm::AsyncOrmGeneratedRecordSession + radixdb_orm::AsyncOrmGeneratedQuerySession<Error = <S as radixdb_orm::AsyncOrmGeneratedRecordSession>::Error>, <S as radixdb_orm::AsyncOrmGeneratedRecordSession>::Error: From<radixdb_orm::RecordError> + From<radixdb_orm::BuilderError> { record.insert_async(session).await?; People::get(1).optional_async(session).await }\n",
939        );
940
941        let temp = std::env::temp_dir().join(format!("radixdb-orm-codegen-{}", std::process::id()));
942        let _ = fs::remove_dir_all(&temp);
943        fs::create_dir_all(temp.join("src")).unwrap();
944        let manifest_dir = env!("CARGO_MANIFEST_DIR").replace('\\', "\\\\");
945        fs::write(
946            temp.join("Cargo.toml"),
947            format!(
948                "[package]\nname = \"radixdb-orm-generated-probe\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\nradixdb-orm = {{ path = {:?} }}\nserde_json = \"1\"\n",
949                manifest_dir
950            ),
951        )
952        .unwrap();
953        fs::write(temp.join("src/lib.rs"), generated.source).unwrap();
954        let status = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
955            .args(["check", "--offline", "--quiet"])
956            .current_dir(&temp)
957            .env("CARGO_TARGET_DIR", temp.join("target"))
958            .status()
959            .unwrap();
960        let _ = fs::remove_dir_all(&temp);
961        assert!(status.success(), "generated Rust facade did not compile");
962    }
963}