-
Notifications
You must be signed in to change notification settings - Fork 90
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Custom validation of built struct #67
Comments
Should just add that I'm willing to help build this. But I want to see how it is received first. |
Regarding this feature together with structured errors. If no validation function is given we should probably not even generate a |
The proposal looks good to me. Thanks for starting the discussion. 👍 Some questions circulating my mind are:
I would prefer to have a rough overall plan for validation and mutation, before implementing just one of these. I agree that validation would be more powerful on the struct level instead of individual fields. With regard to mutation, I'm not so sure. It may be sufficient to offer an API for mutation on the field level? Because if you need mutation on the struct level you could just wrap the build method, no? |
Can I hide the generated build method in the current version? I could easily add my own build method, but I don't get how I would hide the existing one. If we also want mutation/sanitize, we could easily implement so that the built value is passed with ownership to the validator and that the method return it again, possibly modified: Fn(T) -> Result<T, E> where E: std::error::Error But then we might need to do some bikeshedding regarding the name for that functionality, since it's not strictly validating any longer. For sanitizing things, such as keeping numbers in a desired range, I'm not sure what solution I prefer. In general I don't like silently doing stuff like that. I would probably treat an out of range value as a validation error instead of modifying it. |
No you can't hide the build method right now, but it's somewhere on my todo list (I just created a github issue #70). It should not be very difficult to implement, in case you want to give it a go. ;-) I am also a bit wary about encouraging such possibly surprising things like mutating post-processing. The limitation to sanitazing is one of the things I liked about your proposal. But on the other hand I'm not sure if that would rule out some valid use cases and what those use cases might look like / need. Just throwing in possible names
|
Cool, then we agree about the mutating post-processing. I just suggested that because I got the impression you wanted to support that use case. No, I prefer only allowing validation and then error out on invalid settings instead. I think a good start is to just support validation like how I proposed in the beginning. Personally I don't see any use case for |
Ok, then let's go with your original proposal. 👍 Note: It must be I will accept PRs. so if you like, you can work on this and contact me for any questions. These are the important files:
|
Awesome. Thanks for the hints! Will see when I get time to do this, hopefully at least start looking at it today or tomorrow. Will reach out to you if I face problems. |
@faern, wouldn't validations like this appear in |
@TedDriggs Do you mean the To do validation before constructing the struct you mean to send the |
I think validation belongs in the build method. It's however a tough question, whether this should be post- or pre-processing. @faern already pointed out some advantages of both variants. I can add these arguments, from the perspective of the initial proposal:
I'm leaning towards post-processing as initially suggested. Knowing that the final construction is valid is very valuable IMO. |
If this is the intention, I'd imagine that the exporting author should implement a
This keeps argument validation in the target type, enables someone to bypass the builder completely without bypassing validation, and allows the author to decide how to handle defaults; they can keep the source of truth on defaults in the builder, or they can only read user-specified values from the builder and dispatch to an override on the target type such as CaveatThere may be a philosophical question here: Are
Approach #1 has a lot of merit, but at the moment the suggestion is that deserializing into a builder isn't a priority, which pushed me (and probably others) towards option 2. |
I started leaning towards validation before construction a few posts back actually. It does feel strange to construct an object that we don't even know if it's valid or not yet. I'm working on this and I will bring a draft of that to a show-and-tell PR later for discussion :) I'm not particularly fond of doing stuff in the target type at all. It feels like it creates circular dependencies between the two structs. Take Serializing builders sounds strange to me. The builder is not a data struct meant for moving around IMO, it's a factory that sits in one place and spits out targets. I had not even thought about the serializiation problem. I just imagined the builder to be the source of truth for target instances. I don't understand your point 2 really. If other libraries want to use your target type they should also import your builder. Where I currently use I'm not sure what is best any longer. I only thought about builders as the one and only way to create target types and they contain all the logic, while the targets can rest assured they are always OK. Idea: Treat builders as primary creators of targets and put all logic in builder. When people have many ways to create targets it's less of our responsibility so those people can ignore this custom validation features and they implement their validation directly on the target and they call it manually. They have to call that manually after serde deserialization anyway, so would be more symmetrical if they had to call that manually for all their means of creating that type. I will continue coding, will be easier to discuss when we have examples at hand. |
@TedDriggs Ok, keeping the validation function on the target type makes sense to me. This would be a minor change to the original proposal and it could be a private function impl Foo {
fn my_validate(&self) -> Result<(), String> {
// .. as before
}
} But then it becomes much more important what the error type is, because it is supposed to be shared functionality. It would be nice, if the user can pick the error type as long as it is in certain boundaries. We could keep them very loose like The only problem with this is that a procedural macro like |
@faern cool that your working on this. 👍 |
A reasonable limit to the error type is |
@faern agreed, but I'm not talking about serializing builders. Here's the workflow I'm describing:
In order for this to work, I'd need to be able to derive
This is a totally valid interpretation, though at the moment I can't get there because of the deserializing aspect described above. Hand implementing a separate deserialization container is too heavyweight to be interesting.
See #73; I'd prefer to only import the target type, and have that expose a static method to get a builder instance. This keeps the imports shorter, and will improve the refactoring experience that RLS is working on - the "rename type" refactor wouldn't be able to rename the derived builder when the user renames the base type, which would cause build breaks. |
The following is in no way criticism towards anyone. It's just me thinking out loud about use cases in general :) There surely are quite different use cases for builders I have come to realize. And it would of course be awesome if the crate would support as many of them as possible. It would be nice to find the core properties of most use cases and find out which ones can be supported and document them. Then all future issues and PRs can be mapped to them and it would be easy to say if the issue/PR would map nicely into an existing one, if the crate should support yet another use case or if it simply moves in the wrong direction and should not be considered. My main point being that if there are two clusters of use cases that are a bit too far away from each other they might be better solved by two different crates. Not saying this problem exists with current issues/PRs, just trying to draw a mental model here. This might suit better in a separate issue titled "General design goals and supported use cases" or similar :) |
@TedDriggs What about your earlier argument that validation should occur before target struct creation? Or did I interpret that quote wrong? Feels like then we need to put the validation in the builder. Or should the validator on the target type take the builder as argument to validate you mean? Maybe you completely changed stance since that quote. |
Any of you guys available on IRC or some other chat protocol? I'm on the Mozilla IRC server. |
@faern yep - I'm "teddriggs" on there too. |
I've written up a vision doc with an e2e example here. |
I'm not very often on IRC at all, but my nick is |
PS: I have to catch up with your latest arguments later - maybe over the weekend. |
@colin-kiegel totally reasonable; @faern had some questions that were difficult in this high-latency format. I've updated the gist, look forward to both feedback from both of you. Would it be easier for this doc if I submit a dummy PR? That would let us add inline comments etc. |
@TedDriggs sure we can try that |
Where to do validationOption 1: post-processing on target type
Variant 1b: ... via validation trait
Option 2: pre-processing on builder type
=> My impression is that we have much stronger arguments for option 1 / 1b. My current favourite is 1b, but it might be tricky to get a We can add a note to the documentation that pre-processing can be easily achieved by manually wrapping a private renamed build function #70. Deserializing a builder@TedDriggs Whether/How there will be a way to de/serialize a builder seems orthogonal to how the build method works (including validation) IMO. Let's discuss it in another ticket if possible (e.g. #43), or please try again to explain why this is related to validation. :-) |
What do you think about my attempt to summarize the pros/cons of where to put the validation method? PS: I've sent both of you my email address via IRC. In case you have questions about the code base or other chit-chat. ^^ |
I didn't get the email address in IRC; my client doesn't seem to do well when my computer isn't online :/ Having validation run as a post-processing step on the target type feels wrong, because it means that for a period of time - however brief - an object of If validation lives on the target type but in a custom constructor, then the author doesn't need to take a dependency on the builder type, and has a clear place to add any side-effects that may occur during validation or construction. |
Ok, side-effects are definitely an interesting aspect. :-) With the current derive_builder they can only happen when we create a defaulted value for some field (independently of whether we move that value into a new instance of But we can already run into similar situations, because right now default-fns can both have side-effects and/or fail ( One thing I don't like about the custom constructor approach is field ordering (again ^^). Say if we have multiple required fields all of the same type, then changing the field order somewhere might lead to wrong assignments - without compiler warnings. E.g. |
100% agree; that's why in #79 I have the author implement the build function in order to override validation. That way they control the order of parameters read from the builder and passed to the constructor function, and can fully document that process. Having those getters return the |
Hm. Would getters #7 return the default value, if the field is unset but defaulted? If yes, they could have side-effects. If no, then manual composition of the build function would seem complicated - and direct field access could be used as well instead of getters. |
If you write the both the constructor and the build method by yourself, then what do you gain from having a builder at all? You get an extra struct with identical fields wrapped in options and setters for them. Feels like you lose most of the benefits of an automatic builder. |
The setters and optional fields are the biggest chunk of boilerplate to me; I'd happily write one constructor method which doesn't even have to expose all the For cases where a struct literal works for initialization, I can see some value in letting the user pass a validation function, though I agree with @colin-kiegel's concerns about argument ordering and backwards compatibility. Another approach might be accepting a path to a function that takes |
But you can write the custom constructor and builder method with the current version of the crate, or did I miss something? Since we allow not generating the build method we already support this more manual use case. But there are also use cases where it does not matter if the struct is initialized, validated and then just thrown away and similar. My current use case is just a data only struct, no methods/functionality. And all I need to validate is if two |
I don't think we do support build method suppression; I submitted a PR for it but I don't think it's been merged yet. The parts I'm missing are:
With those two things, the pattern I'm describing comes to fruition. I think the pattern you're describing could be done as part of the build_fn attribute. It would not be allowed if the user not opted to skip the automatic build method, and for hygiene reasons it would be a syn::Path to a function. I could get a PR out for this, though I'm still not clear what the signature should be. |
This adds a validation hook when the macro-generated build function is used. The hook borrows the builder and returns a Result<_, String> if any errors are discovered.
This adds a validation hook when the macro-generated build function is used. The hook borrows the builder and returns a Result<_, String> if any errors are discovered.
Just released with v0.4.6 - again many thanks to @TedDriggs. 🎉 :-) |
It would be nice to allow custom code to validate an object before it is returned from the
build
method. This topic is touched upon in #53 and #56, but none of them are really about this, nor discuss it in detail.I mean to validate the entire struct upon build, not individual fields on set. This is for situations where there are no invalid values for individual fields, but some combinations of field values are invalid.
Example
The value sent to
validatefn
should probably be limited to an identifier, a method name, and the code generated will beSelf::$validatefn(&value)...
Structured errors
Structured errors, instead of
String
is discussed in issue #60. I really like the idea of returning structured errors and support that. This custom validation can be made to support that by changing the requirement onvalidatefn
from (T
is the type we are building):to
And introduce another structured error variant
ValidationFailed(E)
, giving us abuild
method similar to:Allowing the user of
derive_builder
to return any error type they want in their validation function.The text was updated successfully, but these errors were encountered: