Skip to content
Merged

212 #213

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions crates/entity-derive-impl/src/entity/migrations/postgres/ddl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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());
}

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down
18 changes: 18 additions & 0 deletions crates/entity-derive-impl/src/entity/parse/field.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,24 @@ impl FieldDef {
&self.ty
}

/// Inner type of `Option<T>`, 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<T>`.
///
/// Used to determine whether to wrap update fields in `Option`.
Expand Down
11 changes: 11 additions & 0 deletions crates/entity-derive-impl/src/entity/parse/field/column.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>
}
Expand All @@ -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();
Expand Down Expand Up @@ -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()?;
Expand Down
6 changes: 5 additions & 1 deletion crates/entity-derive-impl/src/entity/repository.rs
Original file line number Diff line number Diff line change
Expand Up @@ -503,7 +503,11 @@ fn generate_lookup_method_defs(
) -> Vec<LookupMethodDef> {
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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))),
Expand Down
66 changes: 62 additions & 4 deletions crates/entity-derive-impl/src/entity/sql/postgres/lookup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` 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
Expand Down Expand Up @@ -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<Option<#entity_name>, 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))
}
Expand All @@ -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<bool, Self::Error> {
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)
}
Expand Down Expand Up @@ -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<String>,
}
});

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! {
Expand Down
16 changes: 16 additions & 0 deletions wiki/Attributes-en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` 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<String>,

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`.
Expand Down
Loading