How do y’all organize your Ecto changeset functions? Right now I’ve got a mix of two different styles in a codebase:
“one changeset to rule them all” I just have a changeset that can modify any non-auto fields and use it anywhere I want to perform inserts/updates with the schema
“a changeset per operation” I currently prefer to have one changeset per update operation. So if I have my_field, my_field_type, and my_field_status as three separate fields that usually get updated together I might have a my_fieldset_changeset function that casts and validates just those fields.
I like having a changeset per operation because I know it will only change the fields I intend to change, and (this may be bikeshedding on my part) if I change where data lives it’s easier to find the exact places that change those fields.
But sometimes I find myself wanting to break up changesets more by role, separating the fields that can be updated by my app from the fields that can be updated by a user (or whatever untrusted input). I’m envisioning either a system_changeset/2 and a user_changeset/2 or a changeset/3 that looks something like this:
def changeset(my_resource, system_attrs, user_attrs) do
my_resource
|> cast(system_attrs, __schema__(:fields))
|> cast(user_attrs, @user_editable_fields)
|> other_validations()
end
I kind of like this idea because then anywhere the user passes params, even if they pass params unintended for the operation, we still limit them to only fields they’re allowed to update anyway. So kind of like a middle ground between my existing changeset patterns. So the tradeoff with my per-operation pattern would be giving up some of the understanding of where updates happen for just having fewer changeset functions to worry about.
I do think the changeset/3 example above has one big downside: I use changesets very differently for user-provided data and system-provided data. With untrusted data I perform all the validations and do an insert/update that may return a changeset if the validations fail. If I get something wrong with the system-provided data I don’t want to tell the user they did something wrong or potentially expose fields they know nothing about. With trusted data I don’t do as many validations and do insert!/udpate! because it’s more exceptional for the data to be wrong. I could just call the changeset/3 example with an empty map in either attrs column and only do the updates I want, but I would maybe tend to just do separate changesets at that point.
I guess for me too having a more universal+/system changeset has the bonus of working better for seeds and test fixtures where I may want control of every single field.
But I’m curious how the community tends to organize changesets. What patterns or principles have you found helpful when working with changesets? I feel like I have to be over-complicating the issue.
Me personally, I like to stick with a single schema changeset function and have special changeset functions rarely, when needed. For special fields like :is_admin or something.
I keep separate changesets for different functionality. You can also compose changesets, which I don’t see mentioned often.
E.g. I have a Bookmark schema which holds both user fields (the name and url) and data fetched from the web (title, page content, an image url, etc). Both need to be validated, but sometimes I only need to validate the latter (when fetching later).
The distinction in this case is that the user fields are validated for the user while the metadata fields are validated with very large constraints mostly as a sanity check (e.g. 4096 char limit for a URL, which should never be hit by normal use).
IMO functions are basically free, make as many of them as you need to keep things clear.
The worst possible messes I’ve seen in code have been when everything was jammed through a “universal” function and then the needs of different callsites diverged. Suddenly every place that Foo.change_all_the_things is called needs to be reviewed (it’s covered by tests, right?)
Regarding the last case you mention (test setup / factories) - there’s no rule that says those can’t have their own test-specific modules. I’d keep the “change every column” stuff there in preference to having it sitting around in the main app waiting for misuse.
Absolutely one of my favorite things I’ve picked up from him. That tip alone convinced me to read Elixir In Action. It can be combined with “changeset functions” by abstracting common pieces into the schema when that becomes a good refactoring opportunity.
Hmm yeah that resonates with the part of me that wants a stronger separation between my web layer and the rest of my app.
Yeah I think I’m coming back around to that approach.
I think there’s a balance there, balancing “as many of them as you need” with DRY principles. I’ve written functions before that did essentially the same thing as each other with minor differences, then as “essentially the same thing” became increasingly complex how each function handled the edge cases started unintentionally to diverge a bit. But that’s probably more a sign I needed to refactor at that point. And they weren’t just changeset functions.
I like that idea. Do you find that the common pieces are usually more like entire changeset functions for a subset of fields or more like custom validations and such?
Usually there is a set of base required fields and other validations that I put there. Logic that would be shared between all context functions manipulating changesets, less to DRY anything up and more to make things explicit and as self-documenting as possible. I tend to be really particular about API surface so I generally only cast fields, for example, that are needed by some client for a specific operation, so I don’t find putting cast logic there to be as helpful as it would be if you just include all fields by default.
I’m probably going to get drug through the mud for this, but…
I just put changeset functions directly in my LiveView, not in my models. So far, I’ve never had to reuse them, so I just make them specific to the page they are being used on.
Further, more often than not, the form being rendered does not map 100% cleanly to any model. So there is an an Ecto embedded_schema directly in the LiveView also…
If there is a need for sharing, I’ll refactor into a model or context.