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
[WIP] Birth of the new ParamSerializable objects. #1519
base: main
Are you sure you want to change the base?
Conversation
|
||
class UserWithKeyParams | ||
include Lucky::ParamSerializable | ||
param_key :user |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Personally, I prefer this being explicit rather than implicit. Then skip_param_key
would not be necessary because that would be the default
I think there is an abstraction here that is in between params and model properties. What do you think about adding an object like |
Co-authored-by: Matthew McGarvey <matthewmcgarvey14@gmail.com>
src/lucky/param_serializable.cr
Outdated
{% end %} | ||
else | ||
# NOTE: these come from Avram directly | ||
result = {{ type }}::Lucky.parse(val) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had to change this from .adapter
to ::Lucky
to work with the uploaded file. I kept getting this error:
> 38 | else
> 39 | # NOTE: these come from Avram directly
> 40 | result = Lucky::UploadedFile.adapter.parse(val)
^------
Error: undefined method 'adapter' for Lucky::UploadedFile.class
It's at the compile level, so it must be some sort of require order..
request = HTTP::Request.new("GET", "/", body: "", headers: HTTP::Headers.new) | ||
request.query = URI::Params.encode(data) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is sort of a hack because I couldn't think of a better way. This should be fine for uri encoded params, but would fail for json or multipart... My guess is we could maybe make some overloads? I'm open to ideas on a better way
…ine if a nil value was passed in as nil or not
I've added this in to a project to test how it would work with a model, but the unfortunate sad news is, I don't think it will be able to. Here's a few issues I ran in to.
I don't understand what the error means, or how I would go about fixing it... Maybe these are things that we could eventually find a way around? I'm not sure... One though I had was if we took another look at luckyframework/avram#468 and used the DB::Serializable, maybe that would allow us to handle this better? 🤷♂️ Now, with that said, I created a separate params object using all of the same columns (which included some enums), and it worked perfectly 👌 I was able to set an avram_enum in a field, as well as some arrays, submit the form, and they were all converted to the proper objects on the action side. I'm not really sure what to do at this point, or which direction to take. I'm leaning towards taking some of this code from the params, and trying a separate branch that uses that and attempt to just make SaveOperation handle them... If I go that route, it would keep param parsing and such on the Avram side as it is now. That also means that we need to continue to keep Avram and Lucky pretty tightly coupled. Let me know your thoughts, or ideas. |
@jwoertink I had one thought re: item 2 that you pointed out... It sounds like these param objects are validating the required nature of fields, but could that be the responsibility of the operation? Like, could we basically make all of the properties optional on the ParamSerializable object, then the operation figures out which items are required (as well as all of the other uniqueness/numerical validations)? |
hmm... 🤔 That's not a bad idea. I guess you'd never really use the param object directly. The operation would receive this param object, and then figure out from that what it needs. I still haven't figured out how that looks, but essentially you'd end up with something like # in the operation (under the hood)
name.value = param_obj.name if param_obj.has_source?("name")
email.value = param_obj.email if param_obj.has_source?("email")
#... |
oh, just realized something @stephendolan. Right now I'm just basing off your instance variable which would come from |
I didn't realize that the intention of the was to include it in Avram models. With the annotation dictating what to ignore, is it possible to include it in a model since they have an id? |
Hmm... I think another reason for the params from a model not to care about required/optional fields is because different forms will naturally contain different fields. Even if Maybe rather than this params object knowing how to serialize a model from a form, it should know how to serialize an operation from a form. Avram still wouldn't have to know how that works, but could the ParamSerializer look at all of the That might make even more sense from a usage standpoint, too: get "/search" do
user_params = SaveUser.from_params(params)
SaveUser.create(user_params) do |op, results|
#....
end
end |
I tried to ignore those fields, but it was throwing My initial take on this PR was that it wouldn't be included in models at all. I made the assumption that you would always create a separate object. But I had a chat with @stephendolan on that, and we agreed it felt a lot cleaner and more intuitive if you could just use the model since the columns were already defined. It's less duplication. I gave it a solid effort though. I have a good 16+ hours in to this PR, and I just don't see how we can make this work seamlessly with models as they exist now. Currently where I'm at is one of two options:
If y'all have some other ideas, I'm open. |
I've only had time to briefly look at this. I like the idea of a separate object, but I think that will break the type-safety of HTML forms with permitted attributes. I could be wrong about that, but I couldn't figure out a way to get that to work smoothly. I guess you could pass a I think what may work better would be to move the logic in Avram into Lucky, and have a Does that make sense? I can try to look into this more later and offer something more concrete |
Thanks everyone for all these thoughts. This is a obviously a tricky problem that would be great to solve in a type-safe, user-friendly way. I think the core problem that's been hinted at here is that Params are tightly coupled with Forms, Operations, Models, and Actions. @wontruefree was getting at that here. I really like the seed of this idea: capturing params in an object that is created with serialization. I think it allows us to break apart some of this coupling and provide the type-safety that we want in Lucky. A downside appearsNow, it does come at a tradeoff: more code. For every configuration of params in your application you have to create some object like this: class UserParams
include Lucky::ParamSerializable
property email : String
property password : String
property interests : Array(String)
end @stephendolan and @jwoertink you're right that it does feel really nice to just use the Model here, but the problem is that users don't fill out forms that always match models. Imagine a user confirming their email address from a link in an email. That link has a base64 encoded string with all the properties needed to confirm that user. The params in that case just look like this: class EmailConfirmationParams
property encoded_user : String
end This doesn't match any model in the system, because that's not how we store a user. It's not even a subset of the User model. This feels like a mismatch in our API to me. Instead of trying to smooth that over or place these concerns in an operation, I'd rather try to put an explicit line where there is one while remaining user-customizable. Params come from a request, not a model or operation, and we can make that explicit. One thing we can to make this easier in the default case is to provide a What about operations?Right now, operations do two things (or at least two relevant things here):
I came to the same idea that @paulcsmith is circling around: use a Params object to create the form instead of an Operation. This allows forms to reflect what the user needs to fill out instead of tying themselves to the way the developer modeled the database. All togetherHere's some code to help visualize. Models
class User < BaseModel
table do
column email : String
column encrypted_password : String
column admin : Bool = false
has_many interests : Interest
end
end
class Interest
table do
column title : String
belongs_to user : User
end
end Params
class SignUpParams
include Lucky::ParamSerializable
property email : String
property password : String
property interests : Array(String)
# Note: no Admin here
end Form
form_for Users::Create do
text_input params.email
password_input params.password
fancy_input_for_interests params.interests
# submit, etc
end Action
class Users::Create < BrowserAction
get "/users" do
user_params = SignUpParams.from_params(params)
SignUpUser.create(user_params) do |operation, user|
# ...
end
end
end Operation
class SignUpUser < User::SaveOperation
params SignUpParams # <-- spitballing here
permit_columns email # What's being saved to the User *only*. Doesn't need to relate to params at all.
def save_interests
# runs after save so we have an ID
params.interests.each do |interest|
params = UserInterestParams.new(interest: interest, user_id: id)
CreateAndLinkUserInterests.create!(params) # <-- this is wishful thinking, I know, I know
end
end
end
class CreateAndLinkUserInterests < User::SaveOperation
params UserInterestParams
permit_columns title
attribute title
end whew that's a lot! I'm 100% positive there are flaws here, so help me find them. Rip it apart, even! 😄 I think if we can make something like this work though, it strengthens our type-safety in a relatively easy to understand way. It also potentially cleans up a lot of weirdness that might be hiding, like HTTP params living in Avram or database operations being tightly tied to a form. What do y'all think? |
Thanks for putting all that together @edwardloveall! It actually got me motivated to work on this more. I think I was getting a bit too much tunnel vision. Also, just to keep everyone else in the loop, @edwardloveall and I actually spent a bit of time on a call going over this and we even went spelunking in to other projects to see how others handled this. Overall, this feels like a decent direction, and I'd like to explore this a bit more. I do like the idea of making the html inputs take this param object instead. I'm looking at this as, you're not building a form around a model, but more around a use case. GraphQL works similar in this sense where you have these mutations and queries, but they're not necessarily around a specific table in the DB, but more collective data for a single purpose (which may be comprised of several tables or views..). I will start working out that part, but we're still not out of the woods, yet. Here's a few items that I'm foggy on and would love some ideas for direction.
To address the first item, I have this # imagine this in the operation
name.value = param_object.name if param_object.has_key?("name") Maybe this will work? 🤷♂️ No clue, but if anyone has more ideas on that, I'm open. I'll add more as I think of stuff. Thanks all! |
For your default value/nillable column edge case, what if param objects needed to be populated? class Users::Edit < BrowserAction
get "users/:user_id/edit" do
user = UserQuery.find(user_id)
user_params = UserParams.new(email: user.email, optional_nickname: user.optional_nickname)
html Users::EditPage, params: user_params
end
end We could write shortcuts that made some of the default cases easier: user = UserQuery.find(user_id)
user_params = user.to_params # everything but id, created_at, updated_at |
For your second bullet, I don't mind the idea of a separate shard to handle either a glue between concerns, or splitting out a params shard that each project can have a dependency. We saw that yesterday when looking at Elixir/Phoenix. |
…methods no longer needed
{% end %} | ||
end | ||
|
||
macro param(type_declaration, param_key = nil) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Decided to go with a custom macro here because as the user, you specify name : String
, but it's really name : Lucky::PermittedParam(String)
. This also gives us the ability to determine if the value is nil
by intention, or nil because no value was passed in.
{% type = is_nilable_type ? type_declaration.type.types.reject(&.==(Nil)).first.resolve : type_declaration.type.resolve %} | ||
{% is_array = type.name.starts_with?("Array") %} | ||
# TODO: This probably breaks if the type is Array(String?) | ||
{% core_type = is_array ? type.type_vars.first : type %} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the type is nilable (String?
), then type
is String
, but if it's an Array(String?)
, then core_type
ends up being String?
and not String
.... I think that will cause some issues 🤔
getter {{ type_declaration.var }} : Lucky::PermittedParam({{ type_declaration.type }}){% if is_nilable_type %}?{% end %} | ||
|
||
# TODO: This seems to always raise, even though I'm never calling it | ||
# def {{ type_declaration.var }}=(val : {{ type_declaration.type }}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This would be great to have so you don't try to do something like:
user_params = UserParams.new
user_params.name = "Jeremy"
However, I couldn't get it to stop raising the exception... Maybe I missed something?
annotation ParamField | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this won't be used anymore. It seems you can't read an annotation on a macro directly. It would need to be attached to the instance variable or something.
Purpose
New
Lucky::ParamSerializable
module which takes param data incoming from a user, and serializes it in to usable objects. The values in this object become "permitted" params based on how the data was passed in.Description
This is the start to moving param permissions from Avram up to Lucky. This also starts to open up the possibility as to what we can pass through params.
Issues
Here's a basic example of how things work right now:
Because of this, Avram needs to have a Param type object. If we need
Lucky::Params
to implement something, we have to also tellAvram::Params
to update it as well.On top of that, Operations in Avram also aren't able to take in arrays from params.
Solution
These new "Param" objects can tell you which data it needs, and it knows how to take params from JSON, query string, multipart request, or just a form body, and then convert that data in to an object.
Now imagine we have something that looks like this:
In this case,
user_params
would be an instance that has methods likename
that returns a String, andfriends
that returns Array(String). TheSaveUser
object now just decides which of those values it needs, and worries about the validation of the data, and storing in the database.So what does this
UserParams
look like?What else do we get from this? We can now create params that look like this:
Notice how the
q
,page
, andsort
aren't nested keys, butcity
is. Also notice how we don't have to send some values as they have defaults.Current Issues
HTML Form elements should now take a
Lucky::PermittedParam
. Maybe we can support both Avram::Attribute, and this so it's not a breaking change?Associated objects do work, but they are a bit of a hack. They won't support nested multipart or json params.
This works great when you're dealing with small forms, but large forms could get really tedious. I'm not sure how we could proxy a model data over, but maybe we have a
UserParams.from_model(user)
? This could be an extension that exists inlucky_avram
.The specs pass locally for me, but seem to fail in the CI. There's a bit of weirdness that surrounds the files due to trying to typecast Avram::Uploadable and Lucky::UploadedFile...
We still need to discuss how this will be integrated in to Avram itself. How does the
SaveOperation
know to get the value from this param object? Where does that method live? If it's in Avram, then Avram will have to include Lucky as a dependency. If Avram implements some contract, then Lucky will need to know about that, and what does that look like?Including this module in to your models won't work, but with this path, the idea is that you wouldn't want to do that anyway. I still need to write out a clear and concise description of where this thing fits in the stack so a newcomer can understand the thought process behind why we chose this route.
Checklist
crystal tool format spec src
./script/setup
./script/test