Search code examples
ruby-on-railsactivemodel

ActiveModel - declare a model attribute as an array of AR instances


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

Solution

  • 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.