From 4d1ebdf980d6edb0b57cd3e1dddcd1fe61a33fd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ma=C4=87kowski?= Date: Sat, 21 Feb 2026 19:13:31 +0100 Subject: [PATCH 1/5] feat(orm): add support for raw (`r#`) model and field names --- cot-cli/Cargo.toml | 1 + cot-cli/tests/migration_generator.rs | 68 +++++++++++++++++++ cot-cli/tests/migration_generator/keywords.rs | 9 +++ .../migration_generator/keywords_model.rs | 8 +++ cot-codegen/src/model.rs | 13 +++- cot-codegen/src/symbol_resolver.rs | 3 +- cot-macros/src/query.rs | 31 ++++++--- 7 files changed, 122 insertions(+), 11 deletions(-) create mode 100644 cot-cli/tests/migration_generator/keywords.rs create mode 100644 cot-cli/tests/migration_generator/keywords_model.rs diff --git a/cot-cli/Cargo.toml b/cot-cli/Cargo.toml index df544925..957c8a9a 100644 --- a/cot-cli/Cargo.toml +++ b/cot-cli/Cargo.toml @@ -45,6 +45,7 @@ tracing-subscriber = { workspace = true, features = ["env-filter"] } [dev-dependencies] cot-cli = { path = ".", features = ["test_utils"] } +cot = { workspace = true, features = ["test"] } assert_cmd.workspace = true insta.workspace = true insta-cmd.workspace = true diff --git a/cot-cli/tests/migration_generator.rs b/cot-cli/tests/migration_generator.rs index d1b7f21d..4741c668 100644 --- a/cot-cli/tests/migration_generator.rs +++ b/cot-cli/tests/migration_generator.rs @@ -160,6 +160,74 @@ fn create_models_foreign_key_two_migrations() { assert_eq!(table_name, "cot__child"); } +#[test] +fn create_model_keywords() { + let generator = test_generator(); + let src = include_str!("migration_generator/keywords.rs"); + let source_files = vec![SourceFile::parse(PathBuf::from("main.rs"), src).unwrap()]; + + let migration = generator + .generate_migrations_as_generated_from_files(source_files) + .unwrap() + .unwrap(); + + assert_eq!(migration.migration_name, "m_0001_initial"); + assert!(migration.dependencies.is_empty()); + + let (table_name, fields) = unwrap_create_model(&migration.operations[0]); + assert_eq!(table_name, "cot__keywords"); + assert_eq!(fields.len(), 3); + + let field = &fields[0]; + assert_eq!(field.column_name, "id"); + + let field = &fields[1]; + assert_eq!(field.column_name, "abstract"); + assert_eq!(field.name.to_string(), "r#abstract"); + + let field = &fields[2]; + assert_eq!(field.column_name, "type"); + assert_eq!(field.name.to_string(), "r#type"); +} + +#[test] +fn create_model_keywords_source() { + let generator = test_generator(); + let src = include_str!("migration_generator/keywords.rs"); + let source_files = vec![SourceFile::parse(PathBuf::from("main.rs"), src).unwrap()]; + + let migration = generator + .generate_migrations_as_source_from_files(source_files) + .unwrap() + .unwrap(); + + assert!( + migration + .content + .contains(r#"::cot::db::Identifier::new("abstract")"#) + ); + assert!( + migration + .content + .contains(r#"::cot::db::Identifier::new("type")"#) + ); +} + +#[test] +fn create_model_keywords_model() { + let generator = test_generator(); + let src = include_str!("migration_generator/keywords_model.rs"); + let source_files = vec![SourceFile::parse(PathBuf::from("main.rs"), src).unwrap()]; + + let migration = generator + .generate_migrations_as_generated_from_files(source_files) + .unwrap() + .unwrap(); + + let (table_name, _fields) = unwrap_create_model(&migration.operations[0]); + assert_eq!(table_name, "cot__type"); +} + /// Test that the migration generator can generate a "create model" migration /// for a given model which compiles successfully. #[test] diff --git a/cot-cli/tests/migration_generator/keywords.rs b/cot-cli/tests/migration_generator/keywords.rs new file mode 100644 index 00000000..e09ad1c7 --- /dev/null +++ b/cot-cli/tests/migration_generator/keywords.rs @@ -0,0 +1,9 @@ +use cot::db::{model, Auto, ForeignKey}; + +#[model] +struct Keywords { + #[model(primary_key)] + id: Auto, + r#abstract: String, + r#type: i32, +} diff --git a/cot-cli/tests/migration_generator/keywords_model.rs b/cot-cli/tests/migration_generator/keywords_model.rs new file mode 100644 index 00000000..bf41ba36 --- /dev/null +++ b/cot-cli/tests/migration_generator/keywords_model.rs @@ -0,0 +1,8 @@ +use cot::db::{model, Auto}; + +#[model] +struct r#Type { + #[model(primary_key)] + id: Auto, + name: String, +} diff --git a/cot-codegen/src/model.rs b/cot-codegen/src/model.rs index 6610b070..f2a1a8b0 100644 --- a/cot-codegen/src/model.rs +++ b/cot-codegen/src/model.rs @@ -89,10 +89,15 @@ impl ModelOpts { })? .to_string(); } + let unraw_original_name = if original_name.starts_with("r#") { + original_name[2..].to_string() + } else { + original_name.clone() + }; let table_name = if let Some(table_name) = &args.table_name { table_name.clone() } else { - original_name.clone().to_snake_case() + unraw_original_name.to_snake_case() }; let primary_key_field = self.get_primary_key_field(&fields)?; @@ -205,7 +210,11 @@ impl FieldOpts { self_reference: Option<&String>, ) -> Result { let name = self.ident.as_ref().unwrap(); - let column_name = name.to_string(); + let column_name = if name.to_string().starts_with("r#") { + name.to_string()[2..].to_string() + } else { + name.to_string() + }; let (auto_value, foreign_key) = ( self.find_type("cot::db::Auto", symbol_resolver).is_some(), diff --git a/cot-codegen/src/symbol_resolver.rs b/cot-codegen/src/symbol_resolver.rs index 24d57efa..a69f1450 100644 --- a/cot-codegen/src/symbol_resolver.rs +++ b/cot-codegen/src/symbol_resolver.rs @@ -5,6 +5,7 @@ use std::iter::FromIterator; #[cfg(feature = "symbol-resolver")] use std::path::Path; +use quote::format_ident; #[cfg(feature = "symbol-resolver")] use syn::UseTree; #[cfg(feature = "symbol-resolver")] @@ -88,7 +89,7 @@ impl SymbolResolver { let mut new_segments: Vec<_> = symbol .full_path_parts() .map(|s| syn::PathSegment { - ident: syn::Ident::new(s, first_segment.ident.span()), + ident: format_ident!("{}", s, span = first_segment.ident.span()), arguments: syn::PathArguments::None, }) .collect(); diff --git a/cot-macros/src/query.rs b/cot-macros/src/query.rs index d832e0d3..75776d49 100644 --- a/cot-macros/src/query.rs +++ b/cot-macros/src/query.rs @@ -9,16 +9,23 @@ use crate::cot_ident; #[derive(Debug)] pub(crate) struct Query { model_name: syn::Type, - _comma: Token![,], - expr: Expr, + _comma: Option, + expr: Option, } impl Parse for Query { fn parse(input: ParseStream<'_>) -> syn::Result { + let model_name = input.parse()?; + let _comma: Option = input.parse()?; + let expr = if _comma.is_some() { + Some(input.parse()?) + } else { + None + }; Ok(Self { - model_name: input.parse()?, - _comma: input.parse()?, - expr: input.parse()?, + model_name, + _comma, + expr, }) } } @@ -26,10 +33,18 @@ impl Parse for Query { pub(super) fn query_to_tokens(query: Query) -> TokenStream { let crate_name = cot_ident(); let model_name = query.model_name; - let expr = expr_to_tokens(&model_name, query.expr); - quote! { - <#model_name as #crate_name::db::Model>::objects().filter(#expr) + let base = quote! { + <#model_name as #crate_name::db::Model>::objects() + }; + + if let Some(expr) = query.expr { + let expr_tokens = expr_to_tokens(&model_name, expr); + quote! { + #base.filter(#expr_tokens) + } + } else { + base } } From 4a27ab2c642577b19682e7a46cc9a70de2ca695c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ma=C4=87kowski?= Date: Thu, 26 Feb 2026 14:42:57 +0100 Subject: [PATCH 2/5] cleanup --- cot-cli/Cargo.toml | 1 - cot-cli/tests/migration_generator.rs | 22 +++------ cot-cli/tests/migration_generator/keywords.rs | 4 +- .../migration_generator/keywords_model.rs | 8 ---- cot-codegen/src/model.rs | 48 ++++++++++++++----- cot-macros/src/query.rs | 31 ++++-------- 6 files changed, 51 insertions(+), 63 deletions(-) delete mode 100644 cot-cli/tests/migration_generator/keywords_model.rs diff --git a/cot-cli/Cargo.toml b/cot-cli/Cargo.toml index 957c8a9a..df544925 100644 --- a/cot-cli/Cargo.toml +++ b/cot-cli/Cargo.toml @@ -45,7 +45,6 @@ tracing-subscriber = { workspace = true, features = ["env-filter"] } [dev-dependencies] cot-cli = { path = ".", features = ["test_utils"] } -cot = { workspace = true, features = ["test"] } assert_cmd.workspace = true insta.workspace = true insta-cmd.workspace = true diff --git a/cot-cli/tests/migration_generator.rs b/cot-cli/tests/migration_generator.rs index 4741c668..4fe81ff4 100644 --- a/cot-cli/tests/migration_generator.rs +++ b/cot-cli/tests/migration_generator.rs @@ -175,7 +175,7 @@ fn create_model_keywords() { assert!(migration.dependencies.is_empty()); let (table_name, fields) = unwrap_create_model(&migration.operations[0]); - assert_eq!(table_name, "cot__keywords"); + assert_eq!(table_name, "cot__const"); assert_eq!(fields.len(), 3); let field = &fields[0]; @@ -201,6 +201,11 @@ fn create_model_keywords_source() { .unwrap() .unwrap(); + assert!( + migration + .content + .contains(r#"::cot::db::Identifier::new("cot__const")"#) + ); assert!( migration .content @@ -213,21 +218,6 @@ fn create_model_keywords_source() { ); } -#[test] -fn create_model_keywords_model() { - let generator = test_generator(); - let src = include_str!("migration_generator/keywords_model.rs"); - let source_files = vec![SourceFile::parse(PathBuf::from("main.rs"), src).unwrap()]; - - let migration = generator - .generate_migrations_as_generated_from_files(source_files) - .unwrap() - .unwrap(); - - let (table_name, _fields) = unwrap_create_model(&migration.operations[0]); - assert_eq!(table_name, "cot__type"); -} - /// Test that the migration generator can generate a "create model" migration /// for a given model which compiles successfully. #[test] diff --git a/cot-cli/tests/migration_generator/keywords.rs b/cot-cli/tests/migration_generator/keywords.rs index e09ad1c7..2a2720b5 100644 --- a/cot-cli/tests/migration_generator/keywords.rs +++ b/cot-cli/tests/migration_generator/keywords.rs @@ -1,7 +1,7 @@ -use cot::db::{model, Auto, ForeignKey}; +use cot::db::{Auto, ForeignKey, model}; #[model] -struct Keywords { +struct r#const { #[model(primary_key)] id: Auto, r#abstract: String, diff --git a/cot-cli/tests/migration_generator/keywords_model.rs b/cot-cli/tests/migration_generator/keywords_model.rs deleted file mode 100644 index bf41ba36..00000000 --- a/cot-cli/tests/migration_generator/keywords_model.rs +++ /dev/null @@ -1,8 +0,0 @@ -use cot::db::{model, Auto}; - -#[model] -struct r#Type { - #[model(primary_key)] - id: Auto, - name: String, -} diff --git a/cot-codegen/src/model.rs b/cot-codegen/src/model.rs index f2a1a8b0..1d410c27 100644 --- a/cot-codegen/src/model.rs +++ b/cot-codegen/src/model.rs @@ -1,5 +1,6 @@ use darling::{FromDeriveInput, FromField, FromMeta}; use heck::ToSnakeCase; +use syn::ext::IdentExt; use syn::spanned::Spanned; use crate::symbol_resolver::SymbolResolver; @@ -77,7 +78,7 @@ impl ModelOpts { .map(as_field) .collect::, _>>()?; - let mut original_name = self.ident.to_string(); + let mut original_name = self.ident.unraw().to_string(); if args.model_type == ModelType::Migration { original_name = original_name .strip_prefix("_") @@ -89,15 +90,10 @@ impl ModelOpts { })? .to_string(); } - let unraw_original_name = if original_name.starts_with("r#") { - original_name[2..].to_string() - } else { - original_name.clone() - }; let table_name = if let Some(table_name) = &args.table_name { table_name.clone() } else { - unraw_original_name.to_snake_case() + original_name.to_snake_case() }; let primary_key_field = self.get_primary_key_field(&fields)?; @@ -209,12 +205,8 @@ impl FieldOpts { symbol_resolver: &SymbolResolver, self_reference: Option<&String>, ) -> Result { - let name = self.ident.as_ref().unwrap(); - let column_name = if name.to_string().starts_with("r#") { - name.to_string()[2..].to_string() - } else { - name.to_string() - }; + let name = self.ident.clone().expect("Only structs are supported"); + let column_name = name.unraw().to_string(); let (auto_value, foreign_key) = ( self.find_type("cot::db::Auto", symbol_resolver).is_some(), @@ -372,6 +364,23 @@ mod tests { assert_eq!(model.field_count(), 2); } + #[test] + fn model_opts_raw_name() { + let input: syn::DeriveInput = parse_quote! { + struct r#abstract { + #[model(primary_key)] + id: i32, + name: String, + } + }; + let opts = ModelOpts::new_from_derive_input(&input).unwrap(); + let model = opts + .as_model(&ModelArgs::default(), &SymbolResolver::new(vec![])) + .unwrap(); + assert_eq!(model.name.to_string(), "r#abstract"); + assert_eq!(model.table_name, "abstract"); + } + #[test] fn model_opts_as_model_migration() { let input: syn::DeriveInput = parse_quote! { @@ -467,6 +476,19 @@ mod tests { assert!(field.unique); } + #[test] + fn field_opts_raw_name() { + let input: syn::Field = parse_quote! { + r#abstract: String + }; + let field_opts = FieldOpts::from_field(&input).unwrap(); + let field = field_opts + .as_field(&SymbolResolver::new(vec![]), Some(&"TestModel".to_string())) + .unwrap(); + assert_eq!(field.name.to_string(), "r#abstract"); + assert_eq!(field.column_name, "abstract"); + } + #[test] fn find_type_resolved() { let input: syn::Type = diff --git a/cot-macros/src/query.rs b/cot-macros/src/query.rs index 75776d49..d832e0d3 100644 --- a/cot-macros/src/query.rs +++ b/cot-macros/src/query.rs @@ -9,23 +9,16 @@ use crate::cot_ident; #[derive(Debug)] pub(crate) struct Query { model_name: syn::Type, - _comma: Option, - expr: Option, + _comma: Token![,], + expr: Expr, } impl Parse for Query { fn parse(input: ParseStream<'_>) -> syn::Result { - let model_name = input.parse()?; - let _comma: Option = input.parse()?; - let expr = if _comma.is_some() { - Some(input.parse()?) - } else { - None - }; Ok(Self { - model_name, - _comma, - expr, + model_name: input.parse()?, + _comma: input.parse()?, + expr: input.parse()?, }) } } @@ -33,18 +26,10 @@ impl Parse for Query { pub(super) fn query_to_tokens(query: Query) -> TokenStream { let crate_name = cot_ident(); let model_name = query.model_name; + let expr = expr_to_tokens(&model_name, query.expr); - let base = quote! { - <#model_name as #crate_name::db::Model>::objects() - }; - - if let Some(expr) = query.expr { - let expr_tokens = expr_to_tokens(&model_name, expr); - quote! { - #base.filter(#expr_tokens) - } - } else { - base + quote! { + <#model_name as #crate_name::db::Model>::objects().filter(#expr) } } From 4e4885a6bbb97d10176e9c4b6c2fb1e0e801dff4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ma=C4=87kowski?= Date: Thu, 12 Mar 2026 12:23:37 +0100 Subject: [PATCH 3/5] address review comments --- cot-cli/tests/migration_generator.rs | 5 +++++ cot-cli/tests/migration_generator/keywords.rs | 6 ++++++ cot-codegen/src/model.rs | 5 ++++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/cot-cli/tests/migration_generator.rs b/cot-cli/tests/migration_generator.rs index 4fe81ff4..8da4c31a 100644 --- a/cot-cli/tests/migration_generator.rs +++ b/cot-cli/tests/migration_generator.rs @@ -216,6 +216,11 @@ fn create_model_keywords_source() { .content .contains(r#"::cot::db::Identifier::new("type")"#) ); + assert!( + migration + .content + .contains(r#"::cot::db::Identifier::new("cot__use")"#) + ); } /// Test that the migration generator can generate a "create model" migration diff --git a/cot-cli/tests/migration_generator/keywords.rs b/cot-cli/tests/migration_generator/keywords.rs index 2a2720b5..538ccdf1 100644 --- a/cot-cli/tests/migration_generator/keywords.rs +++ b/cot-cli/tests/migration_generator/keywords.rs @@ -7,3 +7,9 @@ struct r#const { r#abstract: String, r#type: i32, } + +#[model(table_name = "use")] +struct TestModel { + #[model(primary_key)] + id: Auto, +} diff --git a/cot-codegen/src/model.rs b/cot-codegen/src/model.rs index 1d410c27..84ae0585 100644 --- a/cot-codegen/src/model.rs +++ b/cot-codegen/src/model.rs @@ -205,7 +205,10 @@ impl FieldOpts { symbol_resolver: &SymbolResolver, self_reference: Option<&String>, ) -> Result { - let name = self.ident.clone().expect("Only structs are supported"); + let name = self + .ident + .clone() + .expect("Only named struct fields are supported"); let column_name = name.unraw().to_string(); let (auto_value, foreign_key) = ( From f26c978fe177a79e3e69934b7d900ea26b56b370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ma=C4=87kowski?= Date: Thu, 12 Mar 2026 12:28:47 +0100 Subject: [PATCH 4/5] address review comment --- cot-cli/tests/migration_generator/keywords.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cot-cli/tests/migration_generator/keywords.rs b/cot-cli/tests/migration_generator/keywords.rs index 538ccdf1..d6fae8d3 100644 --- a/cot-cli/tests/migration_generator/keywords.rs +++ b/cot-cli/tests/migration_generator/keywords.rs @@ -1,4 +1,4 @@ -use cot::db::{Auto, ForeignKey, model}; +use cot::db::{Auto, model}; #[model] struct r#const { From eef9f8183967eb29dda9971fc475f2055153e843 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ma=C4=87kowski?= Date: Thu, 12 Mar 2026 12:40:26 +0100 Subject: [PATCH 5/5] fix the test --- cot-cli/tests/migration_generator.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cot-cli/tests/migration_generator.rs b/cot-cli/tests/migration_generator.rs index 8da4c31a..265b0560 100644 --- a/cot-cli/tests/migration_generator.rs +++ b/cot-cli/tests/migration_generator.rs @@ -174,7 +174,7 @@ fn create_model_keywords() { assert_eq!(migration.migration_name, "m_0001_initial"); assert!(migration.dependencies.is_empty()); - let (table_name, fields) = unwrap_create_model(&migration.operations[0]); + let (table_name, fields) = unwrap_create_model(&migration.operations[1]); assert_eq!(table_name, "cot__const"); assert_eq!(fields.len(), 3);