require 'active_record' require 'active_support' module ActiveRecord module DateRangeScopes extend ActiveSupport::Concern module ClassMethods ## # Defines 3 date range scopes named after the given `name`: # - {name}_between # - {name}_after # - {name}_before # # By default, uses {name}_at as the column name. Column should be a :datetime (timestamp) column. # # Examples: # # class Book < ActiveRecord::Base # # This defines created_between, created_after, created_before scopes # date_range_scopes :created # # # This defines updated_between, updated_after, updated_before scopes # date_range_scopes :updated # end # # class Author < ActiveRecord::Base # # You can specify a different column name to use for the scopes, and optionally, any joins # # or other clauses you need to add to the relation. # date_range_scopes :with_any_books_created, ->{ Book.arel_table[:created_at] }, ->(_) { joins(:books) } # end # # # This would be the same as: # class Author < ActiveRecord::Base # scope :with_any_books_created_between, ->(after, before) { # with_any_books_created_after(after). # with_any_books_created_before(before) # } # scope :with_any_books_created_after, ->(date_or_time) { # next unless date_or_time # # joins(:books). # where( # Book.arel_table[:created_at].gteq( time_or_beginning_of_day(date_or_time) ) # ) # } # scope :with_any_books_created_before, ->(date_or_time) { # … # } # end # def date_range_scopes( name, arel_attr = -> { arel_table[:"#{name}_at"] }, relation = :itself ) relation = relation.to_proc scope :"#{name}_between", ->(after, before) { public_send(:"#{name}_after", after). public_send(:"#{name}_before", before) } scope :"#{name}_after", ->(date_or_time) { next unless date_or_time instance_eval(&relation). where( arel_attr.().gteq( time_or_beginning_of_day(date_or_time) ) ) } scope :"#{name}_before", ->(date_or_time) { next unless date_or_time instance_eval(&relation). where( arel_attr.().lteq( time_or_end_of_day(date_or_time) ) ) } end def time_or_end_of_day(date_or_time) if date_or_time.is_a?(Date) date_or_time.in_time_zone.end_of_day else date_or_time.in_time_zone end end def time_or_beginning_of_day(date_or_time) if date_or_time.is_a?(Date) date_or_time.in_time_zone.beginning_of_day else date_or_time.in_time_zone end end ## # Delegates the date filter scopes to an association (requires the scopes to already be # defined there in the `to` model using `date_range_scopes`). # # This uses `Relation.merge` to merge the scope on the associated model, allowing you to reuse # existing scopes on other models. But since these scopes are local (to the model in which you # call `delegate_date_range_scopes`), the query returns instances of the local model rather than # instances of the associated model. # # Unlike date_range_scopes, where you specify which *column* to operate on, this lets you specify # which *scope* to delegate to in the target (`to`) model. If `scope` not specified, uses the # same scope name as in the source model, removing `{target}s_` prefix if there is one. # # Example: # # class Book < ActiveRecord::Base # date_range_scopes :created # end # # class Author < ActiveRecord::Base # # Delegates to Book.created* scopes by default # delegate_date_range_scopes :books_created, ->(_) { joins(:books) }, to: Book # # Explicitly tells it to delegate Author.with_any_books_written_* to Book.created_ # delegate_date_range_scopes :with_any_books_written, ->(_) { joins(:books) }, to: Book, scope: :created # end # # Author.with_any_books_written_before('1970-01-01') # def delegate_date_range_scopes( local_name, relation = :itself, to:, scope: nil ) relation = relation.to_proc target_model = to name_on_target = scope || local_name.to_s.sub(/^#{target_model.model_name.plural}_/, '') scope(:"#{local_name}_between", ->(after, before) { public_send(:"#{local_name}_after", after). public_send(:"#{local_name}_before", before) }) scope(:"#{local_name}_after", ->(date_or_time) { next unless date_or_time instance_eval(&relation). merge( target_model.public_send(:"#{name_on_target}_after", date_or_time) ) }) scope(:"#{local_name}_before", ->(date_or_time) { next unless date_or_time instance_eval(&relation). merge( target_model.public_send(:"#{name_on_target}_before", date_or_time) ) }) end end end end ActiveRecord::Base.class_eval do include ActiveRecord::DateRangeScopes end