From 3af077b6875ef6bccab6ae1b7b71a0221e828043 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/4] 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 0c2fa70a..5d194bbe 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 98c6f969cdbeaac8f331accacf3137c40a80999d 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/4] 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 5d194bbe..0c2fa70a 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 8f3d0f1d79d8eef3746eeaeac237e7d445da8eb7 Mon Sep 17 00:00:00 2001 From: xelab04 Date: Mon, 9 Mar 2026 23:02:50 +0400 Subject: [PATCH 3/4] feat: add field_name to field opts --- cot-codegen/src/model.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cot-codegen/src/model.rs b/cot-codegen/src/model.rs index 1d410c27..04b4b63f 100644 --- a/cot-codegen/src/model.rs +++ b/cot-codegen/src/model.rs @@ -146,6 +146,7 @@ pub struct FieldOpts { pub ty: syn::Type, pub primary_key: darling::util::Flag, pub unique: darling::util::Flag, + pub field_name: Option, } impl FieldOpts { @@ -206,7 +207,12 @@ impl FieldOpts { self_reference: Option<&String>, ) -> Result { let name = self.ident.clone().expect("Only structs are supported"); - let column_name = name.unraw().to_string(); + + let column_name = if let Some(specified_field_name) = &self.field_name { + specified_field_name.clone() + } else { + name.unraw().to_string() + }; let (auto_value, foreign_key) = ( self.find_type("cot::db::Auto", symbol_resolver).is_some(), From cec3f11ecd2014525e84fde92e7eea90391da40e Mon Sep 17 00:00:00 2001 From: xelab04 Date: Mon, 9 Mar 2026 23:06:26 +0400 Subject: [PATCH 4/4] add tests for custom field name --- cot-codegen/src/model.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/cot-codegen/src/model.rs b/cot-codegen/src/model.rs index 04b4b63f..8b2f6bf9 100644 --- a/cot-codegen/src/model.rs +++ b/cot-codegen/src/model.rs @@ -495,6 +495,20 @@ mod tests { assert_eq!(field.column_name, "abstract"); } + #[test] + fn field_opts_specified_field_name() { + let input: syn::Field = parse_quote! { + #[model(field_name="test_field")] + test: 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(), "test"); + assert_eq!(field.column_name, "test_field"); + } + #[test] fn find_type_resolved() { let input: syn::Type =