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|
        self[column] = (v ? Time.current : nil); 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(:user1)
  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?

  # 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.admin_at = Time.current
  travel_to 1.minute.from_now
  user.save!
  user.reload

  assert_equal Time.current, user.admin_at
  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