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
-
#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.
-
#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.
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_nameoption.
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
:asoption.
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 |