diff --git a/es-entity-macros/src/repo/list_by_fn.rs b/es-entity-macros/src/repo/list_by_fn.rs index a295a8c..7339139 100644 --- a/es-entity-macros/src/repo/list_by_fn.rs +++ b/es-entity-macros/src/repo/list_by_fn.rs @@ -45,7 +45,7 @@ impl CursorStruct<'_> { let nulls = if ascending { "FIRST" } else { "LAST" }; if self.column.is_id() { format!("id {dir}") - } else if self.column.is_optional() { + } else if self.column.is_nullable_column() { format!("{0} {dir} NULLS {nulls}, id {dir}", self.column.name()) } else { format!("{} {dir}, id {dir}", self.column.name()) @@ -59,7 +59,7 @@ impl CursorStruct<'_> { if self.column.is_id() { format!("COALESCE(id {comp} ${id_offset}, true)") - } else if self.column.is_optional() { + } else if self.column.is_nullable_column() { // The OR-clause's COALESCE fires when `col {comp} ${cursor}` is NULL, // which happens whenever either side of the comparison is NULL. The // fallback decides whether the row is "after" the cursor in @@ -886,4 +886,112 @@ mod tests { assert_eq!(tokens.to_string(), expected.to_string()); } + + #[test] + fn list_by_fn_nullable_attribute_emits_nullable_aware_sql_for_non_option_type() { + // The `nullable` attribute lets non-Option Rust types (e.g. domain + // enums whose custom sqlx::Encode writes NULL for one variant) opt + // into the nullable-aware cursor SQL form. The Rust type is NOT + // syntactically Option, but the emitted SQL should match what an + // Option column would get: `NULLS FIRST/LAST` ordering, the + // `IS NOT DISTINCT FROM` cursor form, and the direction-aware NULL + // fallback. + // + // The query parameter cast (else branch of query_arg_tokens) still + // wraps the Rust type in Option<...> because is_optional() remains + // false — the type itself drives binding, while the new + // is_nullable_column() drives SQL shape. + let id_type = Ident::new("EntityId", Span::call_site()); + let entity = Ident::new("Entity", Span::call_site()); + let query_error = syn::Ident::new("EntityQueryError", Span::call_site()); + let column = Column::new_nullable( + syn::Ident::new("value", proc_macro2::Span::call_site()), + syn::parse_str("DomainEnum").unwrap(), + ); + let cursor_mod = Ident::new("cursor_mod", Span::call_site()); + + let persist_fn = ListByFn { + ignore_prefix: None, + column: &column, + id: &id_type, + entity: &entity, + table_name: "entities", + query_error, + delete: DeleteOption::No, + cursor_mod, + any_nested: false, + post_hydrate_error: None, + #[cfg(feature = "instrument")] + repo_name_snake: "test_repo".to_string(), + }; + + let mut tokens = TokenStream::new(); + persist_fn.to_tokens(&mut tokens); + + let expected = quote! { + pub async fn list_by_value( + &self, + cursor: es_entity::PaginatedQueryArgs, + direction: es_entity::ListDirection, + ) -> Result, EntityQueryError> { + self.list_by_value_in_op(self.pool(), cursor, direction).await + } + + pub async fn list_by_value_in_op<'a, OP>( + &self, + op: OP, + cursor: es_entity::PaginatedQueryArgs, + direction: es_entity::ListDirection, + ) -> Result, EntityQueryError> + where + OP: es_entity::IntoOneTimeExecutor<'a> + { + let __result: Result, EntityQueryError> = async { + let es_entity::PaginatedQueryArgs { first, after } = cursor; + let (id, value) = if let Some(after) = after { + (Some(after.id), Some(after.value)) + } else { + (None, None) + }; + + let (entities, has_next_page) = match direction { + es_entity::ListDirection::Ascending => { + es_entity::es_query!( + entity = Entity, + "SELECT value, id FROM entities WHERE ((value IS NOT DISTINCT FROM $3) AND COALESCE(id > $2, true) OR COALESCE(value > $3, value IS NOT NULL)) ORDER BY value ASC NULLS FIRST, id ASC LIMIT $1", + (first + 1) as i64, + id as Option, + value as Option, + ) + .fetch_n(op, first) + .await? + }, + es_entity::ListDirection::Descending => { + es_entity::es_query!( + entity = Entity, + "SELECT value, id FROM entities WHERE ((value IS NOT DISTINCT FROM $3) AND COALESCE(id < $2, true) OR COALESCE(value < $3, $2 IS NULL OR (value IS NULL AND $3 IS NOT NULL))) ORDER BY value DESC NULLS LAST, id DESC LIMIT $1", + (first + 1) as i64, + id as Option, + value as Option, + ) + .fetch_n(op, first) + .await? + }, + }; + + let end_cursor = entities.last().map(cursor_mod::EntityByValueCursor::from); + + Ok(es_entity::PaginatedQueryRet { + entities, + has_next_page, + end_cursor, + }) + }.await; + + __result + } + }; + + assert_eq!(tokens.to_string(), expected.to_string()); + } } diff --git a/es-entity-macros/src/repo/options/columns.rs b/es-entity-macros/src/repo/options/columns.rs index a2551cb..3b54ff7 100644 --- a/es-entity-macros/src/repo/options/columns.rs +++ b/es-entity-macros/src/repo/options/columns.rs @@ -379,6 +379,17 @@ impl Column { } } + #[cfg(test)] + pub fn new_nullable(name: syn::Ident, ty: syn::Type) -> Self { + Column { + name, + opts: ColumnOpts { + nullable: Some(true), + ..ColumnOpts::new(ty) + }, + } + } + pub fn for_id(ty: syn::Type) -> Self { Column { name: syn::Ident::new("id", proc_macro2::Span::call_site()), @@ -387,6 +398,7 @@ impl Column { is_id: true, list_by: Some(true), find_by: Some(true), + nullable: None, list_for_opts: None, parent_opts: None, create_opts: Some(CreateOpts { @@ -412,6 +424,7 @@ impl Column { is_id: false, list_by: Some(true), find_by: Some(false), + nullable: None, list_for_opts: None, parent_opts: None, create_opts: Some(CreateOpts { @@ -443,6 +456,12 @@ impl Column { self.opts.is_id } + /// True iff the Rust type is syntactically `Option`. + /// + /// Drives how the macro casts query parameters and destructures the + /// cursor — both of which depend on the Rust type's shape (Option vs. + /// bare). For the SQL question "is this column nullable?" use + /// [`Self::is_nullable_column`] instead. pub fn is_optional(&self) -> bool { if let syn::Type::Path(type_path) = self.ty() && type_path.path.segments.len() == 1 @@ -455,6 +474,20 @@ impl Column { false } + /// True iff the underlying SQL column can hold NULL. + /// + /// Returns true when the Rust type is `Option` *or* the column was + /// explicitly annotated `nullable` in the repo declaration. The latter + /// covers types like a domain enum whose custom `sqlx::Encode` + /// implementation maps one variant to SQL NULL — the macro otherwise + /// can't see that nullability via syntactic inspection. + /// + /// Drives `ORDER BY ... NULLS FIRST/LAST` emission and the nullable-aware + /// cursor `WHERE` clause in [`Self::condition`]. + pub fn is_nullable_column(&self) -> bool { + self.is_optional() || self.opts.nullable() + } + pub fn name(&self) -> &syn::Ident { &self.name } @@ -551,6 +584,16 @@ struct ColumnOpts { find_by: Option, #[darling(default)] list_by: Option, + /// Opt-in flag for columns whose Rust type is not syntactically `Option` + /// but whose underlying SQL column is nullable. When set, the macro emits + /// the same nullable-aware cursor SQL (`IS NOT DISTINCT FROM`, `NULLS + /// FIRST/LAST` ordering, direction-aware NULL fallback) as it would for an + /// `Option` column. The Rust type itself must implement `sqlx::Encode` + /// in a way that maps to a nullable database column — typically by + /// encoding one variant as `IsNull::Yes` or by exposing a `Type::type_info` + /// matching `Option`. + #[darling(default)] + nullable: Option, #[darling(default, rename = "list_for")] list_for_opts: Option, #[darling(default, rename = "parent")] @@ -570,6 +613,7 @@ impl ColumnOpts { is_id: false, find_by: None, list_by: None, + nullable: None, list_for_opts: None, parent_opts: None, create_opts: None, @@ -586,6 +630,10 @@ impl ColumnOpts { self.list_by.unwrap_or(false) } + fn nullable(&self) -> bool { + self.nullable.unwrap_or(false) + } + fn list_for(&self) -> bool { self.list_for_opts.is_some() }