In my Rails 7.2 app, I have a class of Query
POROs which are each a way to gather, validate and process params that filter queries for records (indexes) of a given model.
Here's an example. The Name
Query subclass's params are defined like this:
module Query::Name
def parameters
{
created_at: [:time],
updated_at: [:time],
ids: [Name],
users: [User],
topics: [Topic],
misspellings: { string: [:no, :either, :only] },
text_name_has: :string,
with_notes: :boolean
}
end
# methods that parse the parameter values if present...
To get a filtered index of Names, you can instantiate one of these objects with whichever params you want. The subclass validates your params for that model, and then translates them into the AR scopes that should return your results. Something like this:
Query::Name.new(created_at: two.years.ago, with_author: true,
topics: ["Soup", "Nuts"], users: ["prithi", "fred"])
# generates:
@query = Name.created_at("2022-08-01").with_author(true).
topics(3323, 2165).users(54333, 7342).order(:created_at, :desc)
So far, so good, this pattern has been working fine for 10 years.
I'm starting to get familiar with using ActiveModel
as a way to assemble and validate "model-like" form data, though, and i'm thinking this could be useful for these Query subclasses.
In the existing param definitions above, :ids
, :users
and :topics
are sort of like a has_many
relationship, but they are not linked to Query in the db since Queries are not db-backed.
These params are written to accept arrays of ids or of instances of AR models for convenience. The param declarations specify the model for the sake of validation. You can't send a Book
instance for the :users
param, but you can send a User
instance, and it will look up the correct id.
It seems like serialization is the way to say an ActiveModel
attribute should store an array of IDs. What I think i want is to use the before_validation
hook to check if the incoming params are instances, validate the type, and map them into an array of ids.
Is this an idiomatic "Rails" approach? Is it a good approach?
class Query::Name
include ActiveModel::Model
include ActiveModel::Attributes
attribute :created_at, :datetime
attribute :updated_at, :datetime
serialize :ids, Array
serialize :users, Array
serialize :topics, Array
enum :misspellings, { :no, :either, :only }
attribute :text_name_has, :string
attribute :with_notes, :boolean
before_validation: :get_ids_of_instances_by_type
You're making the mistake of conflating "the Rails way" with ActiveRecord.
Rails can actually be used with many different ORM's and a lot of the features of ActiveRecord only actually make sense in the context of a relational database and history.
serialize
is one such feature - it's a very specific method to store structured data (arrays of stuff, JSON, YAML, marshalled Ruby objects) in a varchar/text column that dates back to the dark ages before RDBMS's had the native JSON/JSONB (etc) types you probably shouldn't be using. So you would just ignore your better judgement and just stuff the mess into a single column and hope you never have to query that data.
Today it serves as a footgun ("Waaah why can't I query my JSON column?") and some pretty niche scenarios where you want to store marshalled Ruby objects and under the covers by the application level encryption added in Rails 7. It's not in any way relevant to a PORO and don't conflate it with with the much broader subject of serialization.
Also not all objects have to behave like models. You can use the API's provided by ActiveModel for convience but trying to replicate the behavior of an association which is an ActiveRecord abstraction won't necissarily be an improvement. There is value in keeping things simple.
# Do not use :: to declare nested constants.
module Query
# Can you think of something less vague?
class Name
include ActiveModel::Model
include ActiveModel::Attributes
...
attribute :ids, :array
attribute :users, :array
attribute :topics, :array
...
end
end
I would just make the naming less vague and go for name_ids
and user_ids
, topic_ids
etc as it's not actually an association and you don't want to give the wrong idea.
enum
maps an integer column to a set of states and doesn't actually work without ActiveRecord as it uses many of the methods added by it. In a PORO that would just be done with an instance variable or an attribute and you can just deal with a symbol directly dealing with turning it into a integer and back again. While the mutators and inquistion methods are nice you can easily do the same thing with a tiny bit of metaprogramming.