Module: HasHelpers::ActiveRecord::DateRange

Defined in:
lib/has_helpers/active_record/date_range.rb

Constant Summary collapse

END_DATE_SANITY_ERR_MSG =
"%s must not be before the %s"
DATE_RANGE_OVERLAP_ERR_MSG =
"must be unique within the given date range"

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.end_date_or_default(date) ⇒ Object



223
224
225
# File 'lib/has_helpers/active_record/date_range.rb', line 223

def self.end_date_or_default(date)
  date || ::Date::Infinity.new
end

.start_date_or_default(date) ⇒ Object



219
220
221
# File 'lib/has_helpers/active_record/date_range.rb', line 219

def self.start_date_or_default(date)
  date || -::Date::Infinity.new
end

Instance Method Details

#has_distinct_date_range(start_attr: :start_date, end_attr: :end_date, strict_scopes: [], nullable_scopes: [], siblings: nil, as: :date_range, scope_name: nil) ⇒ Object

Adds validations to prevent records from having date ranges which overlap. These validations can be scoped to the uniqueness of any number of attributes by using the strict and nullable scope options. The current record is validated against other records provided by the :siblings option.

In addition to validations calling has_distinct_date_range creates:

  • A scope for accessing records with date ranges covering a particular date. See the :scope_name option.

Finally, has_distinct_date_range includes everything from has_valid_date_range.

Siblings

Siblings are the related records against which the validation will be checked. I.e. these are all the records which the current record potentially conflicts with.

The default siblings options is a proc which gets the owner of the current record and then gets all the sibling records from the owner. The siblings records are taken to be records from a scoped association (by default this is named current_<base_association_name>) plus any additional records from the base association (duplicates excluded), where the base_association_name is generated from the class name of the current object. It is up to the user to define such a scope on the owner class. If a scoped association does not exist or has no records cached in memory, then the sibling records are sourced from the base association.

If any portion of the siblings logic needs to be overridden then it must be overridden in its entirety.

Examples

has_distinct_date_range( start_attr: :start_date, end_attr: :end_date, strict_scopes: :user_id, nullable_scopes: [:product_type_id, :product_category_id], siblings: -> { owner.others }, as: :date_range )

# Same as the previous example, but the start, end and siblings parameters are
# omitted because the default values are sufficient.
has_distinct_date_range(
strict_scopes: :user_id,
nullable_scopes: [:product_type_id, :product_category_id]
)

Options

:strict_scopes: Attributes which should match during overlap checks if both sides are equal. :nullable_scopes: These are the same as :strict_scopes except they match when both sides are equal or either side is nil. :siblings: A collection or proc which returns a collection of objects amongst which the current object should be unique considering the given date range and scopes. :as: Determines the name of the accessor method which is created by has_distinct_date_range. The accessor method returns a range created from the start and end dates. Defaults to :date_range. :scope_name: Determines the name of the ActiveRecord scope created by has_distinct_date_range which returns the records which have a date range which covers the given date. Defaults to :current.



129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
# File 'lib/has_helpers/active_record/date_range.rb', line 129

def has_distinct_date_range(
  start_attr: :start_date,
  end_attr: :end_date,
  strict_scopes: [],
  nullable_scopes: [],
  siblings: nil,
  as: :date_range,
  scope_name: nil
)
  strict_s = Array(strict_scopes)
  nullable_s = Array(nullable_scopes)

  ## distinct date ranges have to be valid date ranges
  has_valid_date_range(start_attr: start_attr, end_attr: end_attr, as: as)

  date_range_accessor_name = as

  # Method which accepts another object and determines whether it overlaps with
  # the current object, considering all defined scopes.
  #
  #=== Examples
  #   object.date_range_overlaps?(other)
  overlap_method_name = "#{ as }_overlaps?"
  unless method_defined?(overlap_method_name)
    define_method(overlap_method_name) do |other, strict_scopes: strict_s, nullable_scopes: nullable_s|
      # An object overlaps if all of the following conditions are true
      # with respect to the current object:

      # * Ids are _not_ equal, or if ids are nil then other is the same as self.
      ((id.nil? && other.id.nil?) ? !self.equal?(other) : id != other.id) &&

        # * Date ranges overlap
        send(date_range_accessor_name).overlaps?(other.send(date_range_accessor_name)) &&

        # * Strict scopes are all equal
        strict_scopes.all? { |attr| send(attr) == other.send(attr) } &&

        # * Nullable scopes all either are equal or have at least one side which is nil.
        nullable_scopes.all? { |attr| send(attr) == other.send(attr) || send(attr).nil? || other.send(attr).nil? }
    end
  end

  # Add a scope which returns the records which have a date range which covers the given
  # date (defaults to the current date), i.e. records which  are "current" on the given date.
  #
  #=== Examples
  #   class Schedule
  #     has_distinct_date_range
  #   end
  #
  #   Schedule.current  # => Returns all Schedules whose date range covers the current date.
  scope_name ||= (as == :date_range) ? :current : "current_for_#{ as }"
  base_association_name = self.base_class.name.demodulize.tableize
  scope scope_name, ->(date = Date.current) do
    where("daterange(:date, :date, '[]') && daterange(#{base_association_name}.#{ start_attr }, #{base_association_name}.#{ end_attr }, '[]')", date: date)
  end

  # Validate that the date range for this object does not overlap with the
  # date range of any of its siblings.
  siblings ||= proc {
    scoped_association_name = "#{ scope_name }_#{ base_association_name }"
    if owner
      # Search for siblings.
      # If a scoped association exists and there are records in-memory for that
      # association then return those records plus any additional records from
      # the base association (excluding duplicates).
      if owner.class.reflect_on_association(scoped_association_name) && owner.send(scoped_association_name).target.any?
        scoped_siblings = owner.send(scoped_association_name)
        scoped_siblings + owner.send(base_association_name).reject { |sibling| scoped_siblings.include?(sibling) }
      else
        # No scoped association exists, or the scoped association has no in-memory records.
        owner.send(base_association_name)
      end
    end
  }
  validate do
    if !marked_for_destruction?
      s = (siblings.respond_to?(:call) ? instance_eval(&siblings.to_proc) : siblings)

      is_sibling_overlap = s.any? { |sibling| send(overlap_method_name, sibling) } if s

      if is_sibling_overlap
        all_scopes = strict_s + nullable_s
        error_msg = "#{ all_scopes.map(&:to_s).map(&:humanize).to_sentence }#{ " " unless all_scopes.empty? }#{ DATE_RANGE_OVERLAP_ERR_MSG }"
        errors.add(start_attr, error_msg)
      end
    end
  end
end

#has_valid_date_range(start_attr: :start_date, end_attr: :end_date, as: :date_range, default_start_date: true) ⇒ Object

Adds validations to prevent records from having date ranges which are inverted -- with the end date before the start date.

In addition to validations calling has_valid_date_range creates:

  • An accessor method which returns a range created from the start and end dates. See the :as option.

Examples

has_valid_date_range( start_attr: :start_date, end_attr: :end_date, as: :date_range )

Options

:as: Determines the name of the accessor method which is created by has_valid_date_range. The accessor method returns a range created from the start and end dates. Defaults to :date_range.

default_start_date Since we need the star_attr to not always be initialized when it arrives empty, we add this option



32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/has_helpers/active_record/date_range.rb', line 32

def has_valid_date_range(
  start_attr: :start_date,
  end_attr: :end_date,
  as: :date_range,
  default_start_date: true
)
  # Normalize start_date. Set it to the current date if nil.
  before_validation do
    send("#{ start_attr }=", Date.current) if send(start_attr).nil? && default_start_date
  end

  # Define an accessor method which returns the date range for the given date options.
  # The name of this method may be customized by passing a value to the :as option,
  # which is by default +:date_range+.
  #
  #=== Examples
  #   class Schedule
  #     has_valid_date_range
  #   end
  #
  #   schedule = Schedule.new(
  #     start_date: "2015-05-04",
  #     end_date: "2015-12-22"
  #   )
  #   schedule.date_range      # => #<Date: 2015-05-04>..#<Date: 2015-12-22>
  unless method_defined?(as)
    define_method(as) do
      (DateRange.start_date_or_default(send(start_attr))..DateRange.end_date_or_default(send(end_attr)))
    end
  end

  # Validate that the end date does not precede the start date.
  validate do
    errors.add(end_attr, END_DATE_SANITY_ERR_MSG % [end_attr.to_s.humanize.titleize, start_attr.to_s.humanize.titleize]) if DateRange.end_date_or_default(send(end_attr)) < DateRange.start_date_or_default(send(start_attr))
  end
end