From 9b69bbcb82e22bb120bb75a40881d7cf097d2a78 Mon Sep 17 00:00:00 2001 From: Michael van Straten Date: Mon, 31 Aug 2026 12:05:44 +0200 Subject: [PATCH] feat: add support for `#[sqlx(other)]` attribute (closes #4391) --- sqlx-core/src/types/mod.rs | 18 ++++++ sqlx-macros-core/src/derives/attributes.rs | 52 +++++++++++++-- sqlx-macros-core/src/derives/decode.rs | 73 +++++++++++++++------- sqlx-macros-core/src/derives/encode.rs | 22 +++++-- tests/postgres/derives.rs | 48 ++++++++++++++ tests/sqlite/derives.rs | 15 +++++ 6 files changed, 196 insertions(+), 32 deletions(-) diff --git a/sqlx-core/src/types/mod.rs b/sqlx-core/src/types/mod.rs index f42d2e3cb8..4e8de92a89 100644 --- a/sqlx-core/src/types/mod.rs +++ b/sqlx-core/src/types/mod.rs @@ -193,6 +193,24 @@ pub use bstr::{BStr, BString}; /// enum Color { Red, Green, Blue } /// ``` /// +/// For enums using the variant names, one variant may be marked with `#[sqlx(other)]` to act +/// as a catch-all when decoding. Any value that does not match another variant is captured in +/// this variant instead of returning an error. When encoding, the captured string is written +/// back verbatim, which the database may reject if it is not a valid value for the type. +/// This is not supported for `#[repr(..)]` enums. +/// +/// ```rust,ignore +/// #[derive(sqlx::Type)] +/// #[sqlx(type_name = "color", rename_all = "lowercase")] +/// enum Color { +/// Red, +/// Green, +/// Blue, +/// #[sqlx(other)] +/// Unknown(String), +/// } +/// ``` +/// /// ### Records /// /// User-defined composite types are supported through deriving a `struct`. diff --git a/sqlx-macros-core/src/derives/attributes.rs b/sqlx-macros-core/src/derives/attributes.rs index 6109863833..d8e2ba5d8b 100644 --- a/sqlx-macros-core/src/derives/attributes.rs +++ b/sqlx-macros-core/src/derives/attributes.rs @@ -1,8 +1,8 @@ use proc_macro2::{Ident, Span, TokenStream}; -use quote::quote_spanned; +use quote::{quote_spanned, ToTokens}; use syn::{ - parenthesized, punctuated::Punctuated, token::Comma, Attribute, DeriveInput, Field, LitStr, - Meta, Token, Type, Variant, + parenthesized, punctuated::Punctuated, token::Comma, Attribute, DeriveInput, Field, Fields, + FieldsUnnamed, LitStr, Meta, Token, Type, Variant, }; macro_rules! assert_attribute { @@ -73,6 +73,7 @@ pub struct SqlxChildAttributes { pub try_from: Option, pub skip: bool, pub json: Option, + pub other: bool, } pub fn parse_container_attributes(input: &[Attribute]) -> syn::Result { @@ -150,6 +151,7 @@ pub fn parse_child_attributes(input: &[Attribute]) -> syn::Result syn::Result syn::Result, + variants: &Punctuated, ) -> syn::Result { let attributes = check_enum_attributes(input)?; assert_attribute!(attributes.repr.is_none(), "unexpected #[repr(..)]", input); + let mut found_other = false; + for variant in variants { + let attributes = parse_child_attributes(&variant.attrs)?; + if attributes.other { + if found_other { + fail!(variant, "#[sqlx(other)] may only be applied to one variant"); + } + found_other = true; + + assert_attribute!( + attributes.rename.is_none(), + "unexpected #[sqlx(rename = ..)]: the #[sqlx(other)] variant matches any value", + variant + ); + + match &variant.fields { + Fields::Unnamed(FieldsUnnamed { unnamed, .. }) if unnamed.len() == 1 => (), + _ => fail!( + { + if matches!(&variant.fields, &Fields::Unit) { + variant.into_token_stream() + } else { + variant.fields.clone().into_token_stream() + } + }, + "#[sqlx(other)] requires exactly one unnamed field, e.g. `Other(String)`" + ), + } + } + } + Ok(attributes) } @@ -301,6 +343,8 @@ pub fn check_struct_attributes( "unexpected #[sqlx(rename = ..)]", field ); + + assert_attribute!(!attributes.other, "unexpected #[sqlx(other)]", field) } Ok(attributes) diff --git a/sqlx-macros-core/src/derives/decode.rs b/sqlx-macros-core/src/derives/decode.rs index 1d4f74df21..49ed4cc44a 100644 --- a/sqlx-macros-core/src/derives/decode.rs +++ b/sqlx-macros-core/src/derives/decode.rs @@ -4,12 +4,13 @@ use super::attributes::{ }; use super::rename_all; use proc_macro2::TokenStream; -use quote::quote; +use quote::{quote, quote_spanned}; use syn::punctuated::Punctuated; +use syn::spanned::Spanned; use syn::token::Comma; use syn::{ - parse_quote, Arm, Data, DataEnum, DataStruct, DeriveInput, Field, Fields, FieldsNamed, Stmt, - TypeParamBound, Variant, + parse_quote, Arm, Data, DataEnum, DataStruct, DeriveInput, Field, Fields, FieldsNamed, + FieldsUnnamed, Stmt, TypeParamBound, Variant, }; pub fn expand_derive_decode(input: &DeriveInput) -> syn::Result { @@ -152,29 +153,46 @@ fn expand_derive_decode_strong_enum( let ident = &input.ident; let ident_s = ident.to_string(); + let mut other_variant = None; - let value_arms = variants.iter().map(|v| -> Arm { - let id = &v.ident; - let attributes = parse_child_attributes(&v.attrs).unwrap(); + let value_arms = variants + .iter() + .map(|v| -> Option { + let id = &v.ident; + let attributes = parse_child_attributes(&v.attrs).unwrap(); - if let Some(rename) = attributes.rename { - parse_quote!(#rename => ::std::result::Result::Ok(#ident :: #id),) - } else if let Some(pattern) = cattr.rename_all { - let name = rename_all(&id.to_string(), pattern); + if attributes.other { + other_variant = Some(v); + return None; + } - parse_quote!(#name => ::std::result::Result::Ok(#ident :: #id),) - } else { - let name = id.to_string(); - parse_quote!(#name => ::std::result::Result::Ok(#ident :: #id),) - } - }); + Some(if let Some(rename) = attributes.rename { + parse_quote!(#rename => ::std::result::Result::Ok(#ident :: #id),) + } else if let Some(pattern) = cattr.rename_all { + let name = rename_all(&id.to_string(), pattern); - let values = quote! { - match value { - #(#value_arms)* + parse_quote!(#name => ::std::result::Result::Ok(#ident :: #id),) + } else { + let name = id.to_string(); + parse_quote!(#name => ::std::result::Result::Ok(#ident :: #id),) + }) + }) + .collect::>(); - _ => Err(format!("invalid value {:?} for enum {}", value, #ident_s).into()) + let default_arm = match other_variant { + Some(variant) => { + let span = match &variant.fields { + Fields::Unnamed(FieldsUnnamed { unnamed, .. }) => { + unnamed.first().expect("unreachable").span() + } + _ => unreachable!(), + }; + let id = &variant.ident; + quote_spanned! { span => other => ::std::result::Result::Ok(#ident :: #id (String::from(other))) } } + None => quote! { + _ => Err(format!("invalid value {:?} for enum {}", value, #ident_s).into()) + }, }; let mut tts = TokenStream::new(); @@ -199,7 +217,10 @@ fn expand_derive_decode_strong_enum( ::sqlx::mysql::MySql, >>::decode(value)?; - #values + match value { + #(#value_arms)* + #default_arm + } } } )); @@ -225,7 +246,10 @@ fn expand_derive_decode_strong_enum( ::sqlx::postgres::Postgres, >>::decode(value)?; - #values + match value { + #(#value_arms)* + #default_arm + } } } )); @@ -251,7 +275,10 @@ fn expand_derive_decode_strong_enum( ::sqlx::sqlite::Sqlite, >>::decode(value)?; - #values + match value { + #(#value_arms)* + #default_arm + } } } )); diff --git a/sqlx-macros-core/src/derives/encode.rs b/sqlx-macros-core/src/derives/encode.rs index 1dc1b5ad10..cd57d50df7 100644 --- a/sqlx-macros-core/src/derives/encode.rs +++ b/sqlx-macros-core/src/derives/encode.rs @@ -4,12 +4,13 @@ use super::attributes::{ }; use super::rename_all; use proc_macro2::{Span, TokenStream}; -use quote::quote; +use quote::{quote, quote_spanned}; use syn::punctuated::Punctuated; +use syn::spanned::Spanned; use syn::token::Comma; use syn::{ parse_quote, Data, DataEnum, DataStruct, DeriveInput, Expr, Field, Fields, FieldsNamed, - Lifetime, LifetimeParam, Stmt, TypeParamBound, Variant, + FieldsUnnamed, Lifetime, LifetimeParam, Stmt, TypeParamBound, Variant, }; pub fn expand_derive_encode(input: &DeriveInput) -> syn::Result { @@ -158,6 +159,17 @@ fn expand_derive_encode_strong_enum( let id = &v.ident; let attributes = parse_child_attributes(&v.attrs)?; + if attributes.other { + let span = match &v.fields { + Fields::Unnamed(FieldsUnnamed { unnamed, .. }) => { + unnamed.first().expect("unreachable").span() + } + _ => unreachable!(), + }; + value_arms.push(quote_spanned!(span => #ident :: #id (other) => other,)); + continue; + } + if let Some(rename) = attributes.rename { value_arms.push(quote!(#ident :: #id => #rename,)); } else if let Some(pattern) = cattr.rename_all { @@ -174,7 +186,7 @@ fn expand_derive_encode_strong_enum( #[automatically_derived] impl<'q, DB: ::sqlx::Database> ::sqlx::encode::Encode<'q, DB> for #ident where - &'q ::std::primitive::str: ::sqlx::encode::Encode<'q, DB>, + for<'a> &'a ::std::primitive::str: ::sqlx::encode::Encode<'a, DB>, { fn encode_by_ref( &self, @@ -184,7 +196,7 @@ fn expand_derive_encode_strong_enum( #(#value_arms)* }; - <&::std::primitive::str as ::sqlx::encode::Encode<'q, DB>>::encode(val, buf) + <&::std::primitive::str as ::sqlx::encode::Encode<'_, DB>>::encode(val, buf) } fn size_hint(&self) -> ::std::primitive::usize { @@ -192,7 +204,7 @@ fn expand_derive_encode_strong_enum( #(#value_arms)* }; - <&::std::primitive::str as ::sqlx::encode::Encode<'q, DB>>::size_hint(&val) + <&::std::primitive::str as ::sqlx::encode::Encode<'_, DB>>::size_hint(&val) } } )) diff --git a/tests/postgres/derives.rs b/tests/postgres/derives.rs index 58d09edbc3..acc0460c61 100644 --- a/tests/postgres/derives.rs +++ b/tests/postgres/derives.rs @@ -130,6 +130,17 @@ enum Mood { Sad, } +// Stale enum type that can absorb any unknown variants +#[derive(PartialEq, Debug, sqlx::Type)] +#[sqlx(type_name = "operation")] +#[sqlx(rename_all = "lowercase")] +enum SafeOperation { + Add, + Subtract, + #[sqlx(other)] + Unknown(String), +} + // Records must map to a custom type // Note that all types are types in Postgres #[derive(PartialEq, Debug, sqlx::Type)] @@ -206,8 +217,12 @@ async fn test_enum_type() -> anyhow::Result<()> { r#" DROP TABLE IF EXISTS people; +DROP TABLE IF EXISTS ops; + DROP TYPE IF EXISTS mood CASCADE; +DROP TYPE IF EXISTS operation CASCADE; + CREATE TYPE mood AS ENUM ( 'ok', 'happy', 'sad' ); DROP TYPE IF EXISTS color_lower CASCADE; @@ -227,11 +242,17 @@ CREATE TYPE color_kebab_case AS ENUM ( 'red-green', 'blue-black' ); CREATE TYPE color_mixed_case AS ENUM ( 'redGreen', 'blueBlack' ); CREATE TYPE color_camel_case AS ENUM ( 'RedGreen', 'BlueBlack' ); +CREATE TYPE operation AS ENUM ( 'add', 'subtract', 'multiply' ); CREATE TABLE people ( id serial PRIMARY KEY, mood mood not null ); + +CREATE TABLE ops ( + id serial PRIMARY KEY, + op operation NOT NULL +); "#, ) .await?; @@ -389,6 +410,33 @@ SELECT id, mood FROM people WHERE id = $1 assert!(rec.0); assert_eq!(rec.1, ColorPascalCase::RedGreen); + let id: i32 = sqlx::query_scalar("INSERT INTO ops (op) VALUES ('multiply') RETURNING id") + .fetch_one(&mut conn) + .await?; + + #[derive(sqlx::FromRow)] + struct OpRow { + id: i32, + op: SafeOperation, + } + + let row: OpRow = sqlx::query_as("SELECT id, op FROM ops WHERE id = $1") + .bind(id) + .fetch_one(&mut conn) + .await?; + + assert_eq!(row.id, id); + assert_eq!(row.op, SafeOperation::Unknown("multiply".to_owned())); + + let id: Option = + sqlx::query_scalar("UPDATE ops SET op = $2 WHERE id = $1 AND op <> $2 RETURNING id") + .bind(id) + .bind(row.op) + .fetch_optional(&mut conn) + .await?; + + assert!(id.is_none()); + Ok(()) } diff --git a/tests/sqlite/derives.rs b/tests/sqlite/derives.rs index 3491ab8539..a5c0704984 100644 --- a/tests/sqlite/derives.rs +++ b/tests/sqlite/derives.rs @@ -13,6 +13,21 @@ test_type!(origin_enum(Sqlite, "2" == Origin::Bar, )); +#[derive(Debug, PartialEq, sqlx::Type)] +#[sqlx(rename_all = "lowercase")] +enum SafeOperation { + Add, + Subtract, + #[sqlx(other)] + Unknown(String), +} + +test_type!(safe_operation_enum(Sqlite, + "'add'" == SafeOperation::Add, + "'subtract'" == SafeOperation::Subtract, + "'multiply'" == SafeOperation::Unknown("multiply".to_string()), +)); + #[derive(PartialEq, Eq, Debug, sqlx::Type)] #[sqlx(transparent)] struct TransparentTuple(i64);