Time Boolean

If you like storing timestamps for your booleans, throw this snippet into your ApplicationRecord for a quick win.

class ApplicationRecord
  class << self
    def time_boolean name,
      column: "#{name}_at",
      negation: "not_#{name}",
      predicate: "#{name}?"

      scope name, -> { where(column => ..Time.current) }
      scope negation,
        -> { where(column => nil).or(where.not(column => ..Time.current)) }

      define_method(name) { !!self[column] && !self[column].future? }
      define_method(:"#{name}=") { |v|
        bool = ActiveRecord::Type::Boolean.new.cast(v)
        self[column] = Time.current if bool && !self[column]
        self[column] = nil unless bool
        v
      }

      alias_method predicate, name
    end
  end
end

Let’s say your user can be admin?.

  1. Make a migration
add_column :users, :admin_at, :datetime
  1. Declare time_boolean in your model
class User < ApplicationRecord
  time_boolean :admin
end

Now you can do the following without having to mess with the timestamp:

  • user.update! admin: true
  • user.admin? # => true
  • user.admin_at = Time.current; user.admin # => true
  • user.admin_at = 1.month.from_now; user.admin # => false
  • User.admin # => [scope of all admins]
  • User.not_admin # => [scope of non-admins]

Here’s a quick test you can throw into your Rails Minitest suite to check most things about this feature based on a real attribute on your real model.

test '#admin acts as a time_boolean' do
  freeze_time

  user = users(:registered)
  assert_nil user.admin_at
  assert_equal false, user.admin?

  # Assignments are synchronized
  user.admin_at = Time.current
  assert_equal true, user.admin?

  user.admin_at = nil
  assert_equal false, user.admin?

  user.admin = true
  assert_equal Time.current, user.admin_at
  assert_equal true, user.admin?

  user.admin = false
  assert_nil user.admin_at
  assert_equal false, user.admin?

  # Non-boolean input is casted to boolean
  user.admin = '0'
  assert_equal false, user.admin?
  user.admin = 'f'
  assert_equal false, user.admin?
  user.admin = ''
  assert_equal false, user.admin?
  user.admin = '1'
  assert_equal true, user.admin?
  user.admin = 't'
  assert_equal true, user.admin?

  # Reassigning the boolean doesn't bump the timestamp
  user.admin_at = Time.current
  travel_to 1.minute.from_now

  assert_no_changes('user.admin_at') { user.admin = true }
  assert_no_changes('user.admin_at') { user.save! }

  user.update! admin: false
  user.update! admin: true
  assert_no_changes('user.admin_at') {
    travel_to 1.minute.from_now
    user.update! admin: true
  }

  # Future time is not true
  user.admin_at = 1.minute.from_now
  assert_equal false, user.admin?

  # It's possible to update the value via either timestamp or boolean
  user.update! admin_at: Time.current
  assert_equal true, user.admin?

  user.admin = false
  user.save!
  user.reload
  assert_equal false, user.admin
  assert_nil user.admin_at

  # Scopes work as expected
  user.update! admin: true
  assert_includes User.admin, user
  refute_includes User.not_admin, user

  user.update! admin: false
  refute_includes User.admin, user
  assert_includes User.not_admin, user

  user.update! admin_at: 1.minute.from_now
  refute_includes User.admin, user
  assert_includes User.not_admin, user

  user.update! admin_at: 1.minute.ago
  assert_includes User.admin, user
  refute_includes User.not_admin, user
end

Code snippets in this post are covered by 0BSD License.



Date
September 18, 2024