The problem

In a blog, one may want to create a menu to filter the blog posts by year and month. A nested list, like:

Querying the data

There’s a few ways to do that, I’ll explore how one could could query the database to retrieve the required data using the array_agg to fetch an array of the months for each year.

The query may look like:

SELECT
  year,
  array_agg(DISTINCT month)
FROM (
  SELECT
    date_part('year', created_at) AS year,
    date_part('month', created_at) AS month
  FROM
    posts
) AS posts_years_months
GROUP BY year;

 year |       array_agg
------+--------------------------
 2020 | {1, 2, 3, 4}

Ruby on Rails side of things

I’ll share here a few ways, that I didn’t see in many blog posts, about how to use this query in a Ruby on Rails application.

“ReadOnly” Model

So, one could create a new model, PostCollection and use the attribute method to define the year and months.

# app/models/post_collection.rb
class PostCollection < ApplicationRecord
  self.table_name = :posts
  self.ignored_columns = columns.map(&:name)

  def readonly?
    true
  end

  attribute :year, :integer
  attribute :months, :integer, array: true

  default_scope {
    query = <<~EOS.squish
      SELECT
        year as year,
        array_agg(DISTINCT month) as months
      FROM (
        SELECT
          date_part('year', created_at) AS year,
          date_part('month', created_at) AS month
        FROM
          posts
      ) AS posts_years_months
      GROUP BY year;
    EOS

    find_by_sql(query)
  }
end
>> PostCollection.all
  PostCollection Load (0.8ms)  SELECT year as year, array_agg(DISTINCT month) as months
  FROM ( SELECT date_part('year', created_at) AS year, date_part('month', created_at)
  AS month FROM posts ) AS posts_years_months GROUP BY year;
=> [#<PostCollection year: 2018, months: [6, 7, 8, 9, 10, 11, 12]>,
#<PostCollection year: 2019, months: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]>,
#<PostCollection year: 2020, months: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]>,
#<PostCollection year: 2021, months: [1, 2, 3, 4, 5, 6]>]

There are a few tricks happening here:

  • def readonly? - The instances of this model cannot update the database.
  • self.ignored_columns = columns.map(&:name) - Ignore all the columns from the table and use only the ones defined in the model. Since this model is only using calculated columns, there’s not need to have attributes related to the table columns.

ActiveModel::Model + ActiveModel::Attributes

Another way to do it would to use a non-ActiveRecord model, instead, using the ActiveModel::Attributes to define the attributes.

# app/models/post_collection.rb
class PostCollection
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :year, :integer
  attribute :months, :array, type: :integer

  def self.all
    query = <<~EOS.squish
      SELECT
        year,
        array_agg(DISTINCT month) as months
      FROM (
        SELECT
          date_part('year', created_at) AS year,
          date_part('month', created_at) AS month
        FROM
          posts
      ) AS posts_years_months
      GROUP BY year;
    EOS

    ApplicationRecord
      .connection
      .select_all(query)
      .to_a
      .map { |record| new(record) }
  end

  def inspect
    format(
      "#<PostCollection %{attributes} >",
      attributes: attributes.map { |key, value| "#{key}=#{value}" }.join(" ")
    )
  end
end

The problem with this is that ActiveModel::Attributes doesn’t provide this :array type. So we need to create it. To avoid reinventing the wheel, you can use the power of the pg gem, to encode/decode the array on the postgres format.

# app/models/types/array.rb
module Types
  class Array < ActiveModel::Type::Value
    def initialize(type:)
      @type = ActiveModel::Type.lookup(type)
    end

    def type
      :array
    end

    def cast(value)
      case value
      when Enumerable
        value
      else
        PG::TextDecoder::Array.new.decode(value).map do |value|
          @type.cast(value)
        end
      end
    end

    def serialize(value)
      PG::TextEncoder::Array.new.encode(Array.wrap(value))
    end
  end
end

As you can see in the code above, after splitting the array, the custom type is casting each object of the array in a given type. You can check what types Ruby on Rails provides by default in this file

At last, the new Array type needs to be registered in the ActiveModel::Type, so we can add a initializer for that

# config/initializers/types.rb
ActiveModel::Type.register(:array, Types::Array)

Postgres View

There’s a very good blog post about that, by Leigh Halliday, in here

Conclusion

It depends.

I don’t think I’d use the ReadOnly Model approach, although it has less code, I think its fake ActiveRecord behavior can mislead other developers.

Writing a Postgres View is nice, if the query is too complex I’d rather keep it in the database. However, it’d require to run migrations to update the query.

So, if the query is not too complex I’d use the ActiveModel::Model + ActiveModel::Attributes approach. I think it requires a reasonable amount of moving pieces, but it doesn’t have the drawbacks of the other approachs.