Support flattened builders
Hello,
bon is great, thanks for sharing it!
I have a use-case where I have a large variety of structs that are all subtly different. I'm wondering if it would be possible to include a macro similar to serde(flatten) that would allow common attributes to be lifted into a different Builder struct, but hide that fact from users of the builder API.
As an example:
#[derive(Builder)]
#[builder(on(String, into))]
struct Common {
commonA: String,
commonB: String,
}
#[derive(Builder)]
#[builder(on(String, into))]
struct Child {
#[builder(flatten)]
common: Common,
customFieldA: String,
}
Which would allow you to do something like:
let child = Child::builder()
.commonA("sets commonA for `Common`'s builder")
.commonB("sets commonB for `Common`'s builder")
.customFieldA("sets customFieldA for `Child`'s builder")
.build() // finalizes both builders
I haven't toyed with this much yet, but it seems like custom fields and methods could enable this currently.
A note for the community from the maintainers
Please vote on this issue by adding a 👍 reaction to help the maintainers with prioritizing it. You may add a comment describing your real use case related to this issue for us to better understand the problem domain.
Hi, thank you for opening the issue!
The idea of #[builder(flatten)] is clear, however, its implementation is quite complicated. The main problem here is that macros don't have access to type information. All they see is a just a stream of tokens underneath.
The compiler invokes every #[derive(Builder)] macro in random (undefined) order and provides them only the source code of the struct they are placed on. So for example, when the second (lower) #[derive(Builder)] macro runs, the compiler only gives this to it:
#[builder(on(String, into))]
struct Child {
#[builder(flatten)]
common: Common,
customFieldA: String,
}
Now, given this context, the macro has no idea what the internal structure of Common is - what fields it has, what their types are, what builder attributes were used on that type, etc - that is completely hidden from this macro invocation on Child struct.
The only way I see this could be worked around is if the macro invocation on Common generates a "macro callback" like this:
// #[derive(Builder)]
// #[builder(on(String, into))]
// struct Common {
// commonA: String,
// commonB: String,
// }
// ^^^^ this generates the following `macro_rules` definition among other code:
macro_rules! with_common_structure {
($callback:ident) => {
// This macro passes the original struct to the callback macro:
$callback! {
#[builder(on(String, into))]
struct Common {
commonA: String,
commonB: String,
}
}
}
}
And then the builder macro on Child can dispatch to the with_common_structure! macro to gain knowledge about its structure.
// #[builder(on(String, into))]
// struct Child {
// #[builder(flatten)]
// common: Common,
// customFieldA: String,
// }
// ^^^^^ this then generates this:
macro_rules! callback_child {
($($tokens:tt)*) => {
bon::generate_with_structure! {
// Here we pass the tokens of `Common`
$($tokens)*
// Plus the tokens of `Child`
#[builder(on(String, into))]
struct Child {
// This way we get the missing context here to know the structure of `Common`
#[builder(flatten)]
common: Common,
customFieldA: String,
}
}
}
}
with_common_structure!(callback_child);
This all already looks quite complex, and yet there is still a problem of "How does the macro on Child invoke with_common_structure?", because that macro either has to be imported into its scope or it must assume it's available under some path. If the users need to manually import it for this to work this all becomes a leaky abstraction =(.
And even then - if we deal with all the problems above somehow. Imagine the Common struct comes from some other module and it uses some types that were imported in its local scope. But then when macro for Child runs - it outputs the generated code in the module where Child is defined and it may not have access to all the types that were imported in the scrope where Common was defined.
For example, if Common uses BTreeSet<String> as one of the types of its fields. Then if the builder macro generates a setter for Child based on the field from Common and makes it accept BTreeSet<String> - the code won't compile unless there is a use std::collections::BTreeSet in the Child module as well. This makes it an even leakier abstraction 💀.
I haven't found the best solution for this problem yet, unfortunately. Technically we could have this feature very limited - where both Common and Child are required to be defined in the same module where we can be sure that all types are imported and the with_common_structure macro is available.
If you'd like to make it work with the existing version of bon, you can do so by using a method-based builder syntax. However, in this case you still need to list all the fields of Common manually when defining the parameters of the new() method like this:
// In this case we may not even need a `#[derive(Builder)]` on `Common`,
// but you can add it if it may be useful somewhere else
struct Common {
commonA: String,
commonB: String,
}
// No `#[derive(Builder)]` on `Child`, instead we'll use method-based syntax below
struct Child {
common: Common,
customFieldA: String,
}
#[bon::bon]
impl Child {
#[builder(on(String, into))]
fn new(
// List all fields from the `Common`
commonA: String,
commonB: String,
// Custom fields too
customFieldA: String,
) -> Self {
// Using struct literal syntax here is fine - we want an exhaustiveness check to make sure
// we listed all fields of `Common`.
let common = Common {
commonA,
commonB,
};
Self {
common,
customFieldA,
}
}
}
fn main() {
Child::builder()
.commonA("commonA")
.commonB("commonB")
.customFieldA("customFieldA")
.build();
}
Thanks so much for the speedy and thorough response. You're right about the lack of type knowledge at macro invocation time, I hadn't considered that.
I also agree that the callback abstraction seems like it would perhaps be too complex, unless it can be papered over by the "same module" requirement, or alternatively perhaps flatten could allow providing a path to the flattened type which would incidentally have the new macro defined under it:
#[derive(Builder)]
struct Child {
#[builder(flatten(type = crate::module::Common))]
common: Common,
customFieldA: String,
}
Where the argument implies that there exists a crate::module::with_common_structure!, perhaps additionally namespaced under a dynamically generated submodule of module.
Anyways, this doesn't solve the scoping issues of the types used in Common, I'm not sure there's a clean answer for that.
Thanks for providing the example for using the new function! I'm currently using declarative macros to reduce some of the repetition by injecting the shared values in each struct ("manually" flattening them.) I think autogenerating these new functions could be a cleaner way to share behaviors by moving them into shared struct types.