Custom Sorting in Administrate

Eli Fatsi, Former Development Director

Article Categories: #Code, #Back-end Engineering

Posted on

Implementing a custom and re-usable Administrate class to enable sorting records with Ruby.

In one of our Administrate dashboards, we display a User's invitation_status. This is a read-only field that we display on the index and show views, and the value is a string derived from the status of related Invitation records (eg: Invitation Sent 10-13-2021, Accepted).

The goal was to sort all User records by this invitation_status. I'm sure it's possible to pull this off in SQL land, but I was curious to see what it'd take to run the sorting logic in Ruby. Administrate lets you easily override the default functionality with impressive percission, so here's the solution.

First, we update the generated UsersController to get our own order functionality in where we want it:

# in app/controllers/admin/users_controller.rb

module Admin
  class UsersController < Admin::AdministratorsController

    def order
      if sorting_attribute == "invitation_status"
        @order ||= CustomSortOrder.new(sorting_attribute, sorting_direction)
      else
        super
      end
    end

    # ...
  end
end

This code will only apply our custom sort logic when the admin is sorting on the invitation_status field. All other sort attempts will pass through to Administrate's default implementation.

Next, we define the CustomSortOrder class that we're referencing above:

# in app/lib/custom_sort_order.rb

class CustomSortOrder < Administrate::Order
  def apply(relation)
    # sort records by attribute
    sorted = relation.sort_by(&attribute.to_sym)

    # assemble array of ids
    ids = sorted.map(&:id)
    if direction.to_sym == :desc
      ids = ids.reverse
    end

    # Arel / SQL magic to order records by the ids array
    id_condition = Arel::Nodes::Case.new(relation.arel_table[:id])
    ids.each_with_index do |id, index|
      id_condition.when(id).then(index)
    end

    # Finally, the ActiveRecord call
    relation.where(id: ids).order(id_condition)
  end
end

That relation.sort_by(&attribute.to_sym) is a little cryptic, that will use Ruby's Enumerable#sort_by method and sort on the value returned when invitation_status (which is the string that attribute holds) is called on each of the records individually.

The next few lines assemble the ids array in the order we want it, nothing fancy here.

Then comes the meat of the ORM logic. This is the first time I've ever cracked into Arel::Nodes::Case before, as per usual props to great and almighty StackOverflow for the support - https://stackoverflow.com/questions/1309624/simulating-mysqls-order-by-field-in-postgresql/66517571#66517571.

For demonstration, let's pretend like our ids array ended up looking like [3, 1, 5]. The resulting SQL query would be:

SELECT "users".*
  FROM "users"
  WHERE "users"."id" IN (3, 1, 5)
  ORDER BY CASE "users"."id"
    WHEN 3 THEN 0
    WHEN 1 THEN 1
    WHEN 5 THEN 2
  END

Neat, huh?

A couple of caveats:

First, this is not an efficient way to sort things, and will not scale well once you get to hundreds or thousands of records. Assuming the method you're sorting on triggers a database query or two, you're looking at a classic N+1 situation. If you can figure out how to sort using a more efficient SQL query, I'd recommend that. If that's tough and your data set is small enough though, this will do the trick.

Second, Administrate doesn't play too well when you start defining attribute types that aren't backed by database attributes. For example, searching in Administrate assembles a database query that checks against all searchable attributes included in the index view. Since our invitation_status field was initially set up as a Field::String type, any search attempt would query the database for roughly invitation_status LIKE '%search_term%' and the database would go "NEVER HEARD OF THAT 💥💥💥". The solution for this is one more custom Administrate subclass:

# in app/fields/non_searchable_string_field.rb

require "administrate/field/base"

class NonSearchableStringField < Administrate::Field::String
  def self.searchable?
    false
  end
end

And in the UserDashboard, we use our new custom field

# in app/dashboards/user_dashboard.rb

class UserDashboard < Administrate::BaseDashboard
  ATTRIBUTE_TYPES = {
    id: Field::Number,
    # ... other attributes
    invitation_status: NonSearchableStringField
  }

  # ...
end

The last required step is to copy+paste the view files from Administrate's String field into a new app/views/fields/non_searchable_string_field directory. Check the docs for generating those initial view files. Our new NonSearchableStringField behaves exactly like a regular Administrate String field, but it's no longer searchable -> problem solved!

Recap / Ode to Administrate #

I love the flexibility that Administrate provides when your use cases push you outside the standard lanes. While solutions like this can feel a little hacky at times (and cause N+1 performance issues), they're generally quick to pull off and result in precise solutions with minimal side effects.

If you've customized Administrate in a cool way, or have suggestions for a better way to pull off custom sorting (maybe I should have leaned into COLLECTION_FILTERS for this use case...) let me know in the comments below!

Related Articles