From 3f9d567d64715565e22752266c703f9fef7bd407 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Sun, 5 Jul 2026 10:14:05 +0700 Subject: [PATCH] #212 feat: case-insensitive unique columns via column(ci) --- .../src/entity/migrations/postgres/ddl.rs | 40 ++++++++++- .../src/entity/parse/field.rs | 18 +++++ .../src/entity/parse/field/column.rs | 11 ++++ .../src/entity/repository.rs | 6 +- .../src/entity/sql/postgres/constraints.rs | 6 +- .../src/entity/sql/postgres/lookup.rs | 66 +++++++++++++++++-- wiki/Attributes-en.md | 16 +++++ 7 files changed, 155 insertions(+), 8 deletions(-) diff --git a/crates/entity-derive-impl/src/entity/migrations/postgres/ddl.rs b/crates/entity-derive-impl/src/entity/migrations/postgres/ddl.rs index 4955937..1c66a24 100644 --- a/crates/entity-derive-impl/src/entity/migrations/postgres/ddl.rs +++ b/crates/entity-derive-impl/src/entity/migrations/postgres/ddl.rs @@ -29,6 +29,9 @@ pub fn generate_up(entity: &EntityDef) -> String { if field.column().has_index() { sql.push_str(&generate_single_index(entity, field)); } + if field.is_unique() && field.column().ci { + sql.push_str(&generate_ci_unique_index(entity, field)); + } } // Composite indexes @@ -87,8 +90,9 @@ fn generate_column_def( parts.push("NOT NULL".to_string()); } - // UNIQUE constraint - if field.is_unique() { + // UNIQUE constraint; ci-unique columns get a functional + // LOWER(...) index in generate_up instead of an inline constraint. + if field.is_unique() && !field.column().ci { parts.push("UNIQUE".to_string()); } @@ -133,6 +137,19 @@ fn generate_single_index(entity: &EntityDef, field: &FieldDef) -> String { format!("CREATE INDEX IF NOT EXISTS {index_name} ON {qualified_table}{using} ({column});\n") } +/// Generate the functional unique index for a `#[column(unique, ci)]` +/// column: uniqueness is enforced on `LOWER(column)`. +fn generate_ci_unique_index(entity: &EntityDef, field: &FieldDef) -> String { + let table = entity.table.clone(); + let column = field.column_name(); + let index_name = format!("{table}_{column}_lower_key"); + let qualified_table = entity.full_table_name_for(&table); + + format!( + "CREATE UNIQUE INDEX IF NOT EXISTS {index_name} ON {qualified_table} (LOWER({column}));\n" + ) +} + /// Generate CREATE INDEX for a composite index. fn generate_composite_index(entity: &EntityDef, idx: &CompositeIndexDef) -> String { let table = entity.table.clone(); @@ -418,6 +435,25 @@ mod tests { assert!(sql.contains("(name, email)")); } + #[test] + fn generate_up_ci_unique_uses_functional_index() { + let entity = parse_entity(quote::quote! { + #[entity(table = "users", migrations)] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, response)] + #[column(unique, ci)] + pub username: String, + } + }); + let sql = generate_up(&entity); + assert!(!sql.contains("username TEXT NOT NULL UNIQUE")); + assert!(sql.contains( + "CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_key ON users (LOWER(username));" + )); + } + #[test] fn generate_composite_index_unique() { let idx = CompositeIndexDef { diff --git a/crates/entity-derive-impl/src/entity/parse/field.rs b/crates/entity-derive-impl/src/entity/parse/field.rs index 70c3e5f..9e0679d 100644 --- a/crates/entity-derive-impl/src/entity/parse/field.rs +++ b/crates/entity-derive-impl/src/entity/parse/field.rs @@ -310,6 +310,24 @@ impl FieldDef { &self.ty } + /// Inner type of `Option`, or the type itself when not optional. + /// + /// Case-insensitive lookups take the unwrapped value: a NULL column + /// never matches a `LOWER(...)` probe, so an `Option` parameter adds + /// nothing but ceremony. + #[must_use] + pub fn option_inner_type(&self) -> &Type { + if let Type::Path(type_path) = &self.ty + && let Some(segment) = type_path.path.segments.last() + && segment.ident == "Option" + && let syn::PathArguments::AngleBracketed(args) = &segment.arguments + && let Some(syn::GenericArgument::Type(inner)) = args.args.first() + { + return inner; + } + &self.ty + } + /// Check if the field type is `Option`. /// /// Used to determine whether to wrap update fields in `Option`. diff --git a/crates/entity-derive-impl/src/entity/parse/field/column.rs b/crates/entity-derive-impl/src/entity/parse/field/column.rs index 01977cf..afe1d55 100644 --- a/crates/entity-derive-impl/src/entity/parse/field/column.rs +++ b/crates/entity-derive-impl/src/entity/parse/field/column.rs @@ -176,6 +176,14 @@ pub struct ColumnConfig { /// Explicitly allow NULL even for non-Option types. pub nullable: bool, + /// Case-insensitive text column. + /// + /// Generated lookups compare via `LOWER(col) = LOWER($n)`; with + /// `unique` + `migrations` the DDL emits a functional + /// `CREATE UNIQUE INDEX ... (LOWER(col))` instead of a plain + /// `UNIQUE` constraint. + pub ci: bool, + /// Custom column name. Defaults to field name. pub name: Option } @@ -193,6 +201,7 @@ impl ColumnConfig { /// - `varchar = N` — Use VARCHAR(N) instead of TEXT /// - `sql_type = "TYPE"` — Override SQL type /// - `nullable` — Allow NULL + /// - `ci` — Case-insensitive text lookups (`LOWER(col)` comparison) /// - `name = "col"` — Custom column name pub fn from_attr(attr: &Attribute) -> Self { let mut config = Self::default(); @@ -232,6 +241,8 @@ impl ColumnConfig { config.pg_enum = Some(value.value()); } else if meta.path.is_ident("nullable") { config.nullable = true; + } else if meta.path.is_ident("ci") { + config.ci = true; } else if meta.path.is_ident("name") { let _: syn::Token![=] = meta.input.parse()?; let value: syn::LitStr = meta.input.parse()?; diff --git a/crates/entity-derive-impl/src/entity/repository.rs b/crates/entity-derive-impl/src/entity/repository.rs index c0bcb1d..2c63f16 100644 --- a/crates/entity-derive-impl/src/entity/repository.rs +++ b/crates/entity-derive-impl/src/entity/repository.rs @@ -503,7 +503,11 @@ fn generate_lookup_method_defs( ) -> Vec { let field_name = field.name(); let field_name_str = field.name_str(); - let field_type = field.ty(); + let field_type = if field.column.ci { + field.option_inner_type() + } else { + field.ty() + }; let find_name = format_ident!("find_by_{}", field_name_str); let exists_name = format_ident!("exists_by_{}", field_name_str); diff --git a/crates/entity-derive-impl/src/entity/sql/postgres/constraints.rs b/crates/entity-derive-impl/src/entity/sql/postgres/constraints.rs index b68e5ab..b34b6a2 100644 --- a/crates/entity-derive-impl/src/entity/sql/postgres/constraints.rs +++ b/crates/entity-derive-impl/src/entity/sql/postgres/constraints.rs @@ -68,7 +68,11 @@ impl Context<'_> { for field in self.entity.all_fields() { let column = field.name_str(); if field.column.unique { - let name = format!("{table}_{column}_key"); + let name = if field.column.ci { + format!("{table}_{column}_lower_key") + } else { + format!("{table}_{column}_key") + }; if covered.insert(name.clone()) { arms.push(quote! { #name => Some((::entity_core::ConstraintKind::Unique, Some(#column))), diff --git a/crates/entity-derive-impl/src/entity/sql/postgres/lookup.rs b/crates/entity-derive-impl/src/entity/sql/postgres/lookup.rs index 379937c..59f4ea6 100644 --- a/crates/entity-derive-impl/src/entity/sql/postgres/lookup.rs +++ b/crates/entity-derive-impl/src/entity/sql/postgres/lookup.rs @@ -64,6 +64,22 @@ fn composite_suffix(fields: &[&FieldDef]) -> String { .join("_and_") } +/// Lookup parameter type and WHERE fragment for a single-column lookup. +/// +/// Case-insensitive columns compare via `LOWER(col) = LOWER($n)` and +/// unwrap `Option` parameters to `T`. +fn lookup_comparison<'f>(field: &'f FieldDef, placeholder: &str) -> (&'f syn::Type, String) { + let column = field.name_str(); + if field.column.ci { + ( + field.option_inner_type(), + format!("LOWER({column}) = LOWER({placeholder})") + ) + } else { + (field.ty(), format!("{column} = {placeholder}")) + } +} + /// `a = $1 AND b = $2` WHERE fragment for a composite lookup. fn composite_where_clause(fields: &[&FieldDef], dialect: &DatabaseDialect) -> String { fields @@ -246,17 +262,17 @@ impl Context<'_> { let field_name = field.name(); let field_name_str = field.name_str(); - let field_type = field.ty(); let method_name = format_ident!("find_by_{}", field_name_str); let placeholder = dialect.placeholder(1); let op = format!("find_by_{field_name_str}"); let span = instrument(&entity_name.to_string(), &op); + let (field_type, where_clause) = lookup_comparison(field, &placeholder); quote! { #span async fn #method_name(&self, #field_name: #field_type) -> Result, Self::Error> { let row: Option<#row_name> = sqlx::query_as( - ::sqlx::AssertSqlSafe(format!("SELECT * FROM {} WHERE {} = {}", #table, stringify!(#field_name), #placeholder)) + ::sqlx::AssertSqlSafe(format!("SELECT * FROM {} WHERE {}", #table, #where_clause)) ).bind(&#field_name).fetch_optional(self).await?; Ok(row.map(#entity_name::from)) } @@ -280,17 +296,17 @@ impl Context<'_> { let field_name = field.name(); let field_name_str = field.name_str(); - let field_type = field.ty(); let method_name = format_ident!("exists_by_{}", field_name_str); let placeholder = dialect.placeholder(1); let op = format!("exists_by_{field_name_str}"); let span = instrument(&entity_name.to_string(), &op); + let (field_type, where_clause) = lookup_comparison(field, &placeholder); quote! { #span async fn #method_name(&self, #field_name: #field_type) -> Result { let exists: bool = sqlx::query_scalar( - ::sqlx::AssertSqlSafe(format!("SELECT EXISTS(SELECT 1 FROM {} WHERE {} = {})", #table, stringify!(#field_name), #placeholder)) + ::sqlx::AssertSqlSafe(format!("SELECT EXISTS(SELECT 1 FROM {} WHERE {})", #table, #where_clause)) ).bind(&#field_name).fetch_one(self).await?; Ok(exists) } @@ -331,6 +347,48 @@ mod tests { assert!(code.contains("fetch_one")); } + #[test] + fn ci_lookup_compares_via_lower_and_unwraps_option() { + let entity = parse_entity(quote::quote! { + #[entity(table = "users")] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, update, response)] + #[column(unique, ci)] + pub username: Option, + } + }); + + let ctx = Context::new(&entity); + let code = ctx.lookup_methods().to_string(); + + assert!(code.contains("async fn find_by_username")); + assert!(code.contains("LOWER(username) = LOWER($1)")); + assert!(code.contains("username : String")); + assert!(!code.contains("username : Option < String >")); + } + + #[test] + fn plain_lookup_keeps_exact_comparison() { + let entity = parse_entity(quote::quote! { + #[entity(table = "users")] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, response)] + #[column(unique)] + pub email: String, + } + }); + + let ctx = Context::new(&entity); + let code = ctx.lookup_methods().to_string(); + + assert!(code.contains("email = $1")); + assert!(!code.contains("LOWER")); + } + #[test] fn composite_unique_index_generates_lookup_pair() { let entity = parse_entity(quote::quote! { diff --git a/wiki/Attributes-en.md b/wiki/Attributes-en.md index 8ea9601..5b2c3ce 100644 --- a/wiki/Attributes-en.md +++ b/wiki/Attributes-en.md @@ -509,6 +509,22 @@ sqlx::query(Order::MIGRATION_UP).execute(&pool).await?; - The declared name is checked against the enum's `PG_TYPE` constant at compile time; a mismatch fails the build - The `ValueObject`'s opt-in `sqlx` flag emits `sqlx::Type` / `Encode` / `Decode` impls; omit it if you already derive `sqlx::Type` +### `#[column(ci)]` + +Case-insensitive text column. Generated `find_by_{field}` / `exists_by_{field}` compare via `LOWER(col) = LOWER($1)`, and `Option` fields unwrap to `T` in the lookup signature (a NULL column never matches a probe). + +```rust +#[field(create, update, response)] +#[column(unique, ci)] +pub username: Option, + +let user = pool.find_by_username(handle).await?; // handle: String +let taken = pool.exists_by_username(handle).await?; +``` + +- With `migrations`, `unique + ci` emits `CREATE UNIQUE INDEX {table}_{column}_lower_key ON {table} (LOWER({column}))` instead of an inline `UNIQUE` constraint +- With `typed_constraints`, violations of that index resolve to the field like any other unique constraint + ### `#[owner]` Row-level ownership scoping. Marks the column carrying the owning principal's id; the repository gains scoped methods that never reveal whether a row exists for another owner and respect `soft_delete`.