Skip to content
Merged
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
112 changes: 110 additions & 2 deletions es-entity-macros/src/repo/list_by_fn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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
Expand Down Expand Up @@ -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<T> 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<T>, but the emitted SQL should match what an
// Option<T> 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<cursor_mod::EntityByValueCursor>,
direction: es_entity::ListDirection,
) -> Result<es_entity::PaginatedQueryRet<Entity, cursor_mod::EntityByValueCursor>, 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<cursor_mod::EntityByValueCursor>,
direction: es_entity::ListDirection,
) -> Result<es_entity::PaginatedQueryRet<Entity, cursor_mod::EntityByValueCursor>, EntityQueryError>
where
OP: es_entity::IntoOneTimeExecutor<'a>
{
let __result: Result<es_entity::PaginatedQueryRet<Entity, cursor_mod::EntityByValueCursor>, 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<EntityId>,
value as Option<DomainEnum>,
)
.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<EntityId>,
value as Option<DomainEnum>,
)
.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());
}
}
48 changes: 48 additions & 0 deletions es-entity-macros/src/repo/options/columns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -443,6 +456,12 @@ impl Column {
self.opts.is_id
}

/// True iff the Rust type is syntactically `Option<T>`.
///
/// 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
Expand All @@ -455,6 +474,20 @@ impl Column {
false
}

/// True iff the underlying SQL column can hold NULL.
///
/// Returns true when the Rust type is `Option<T>` *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
}
Expand Down Expand Up @@ -551,6 +584,16 @@ struct ColumnOpts {
find_by: Option<bool>,
#[darling(default)]
list_by: Option<bool>,
/// Opt-in flag for columns whose Rust type is not syntactically `Option<T>`
/// 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<T>` 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<Inner>`.
#[darling(default)]
nullable: Option<bool>,
#[darling(default, rename = "list_for")]
list_for_opts: Option<ListForOpts>,
#[darling(default, rename = "parent")]
Expand All @@ -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,
Expand All @@ -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()
}
Expand Down