diff --git a/lib/acts_as_span/no_overlap_validator.rb b/lib/acts_as_span/no_overlap_validator.rb index 5657984..90cf05b 100644 --- a/lib/acts_as_span/no_overlap_validator.rb +++ b/lib/acts_as_span/no_overlap_validator.rb @@ -1,51 +1,83 @@ +# frozen_string_literal: true + require 'active_model' module ActsAsSpan + # Validator that checks whether a record is overlapping with others + # + # Takes options `:instance_scope` (optional) and `:scope` (required): + # * `instance_scope` is a proc which, when evaluated by the record, returns + # a boolean value. When false, the validatior will not check for overlap. + # When true, the validator checks normally. + # * `scope` is also a proc. This is must return an ActiveRecord Relation that + # determines which records' spans to compare. + # + # Usage: + # Given a record with `siblings` defined, the most basic use case is: + # ``` + # validates_with ActsAsSpan::NoOverlapValidator, + # scope: proc { siblings } + # ``` + # When this record is validated, every record in the ActiveRecord relation + # `record.siblings` is checked for mutual overlap with `record`. + # + # Use `instance_scope` if there is some condition where a record oughtn't be + # validated for whatever reason: + # ``` + # validates_with ActsAsSpan::NoOverlapValidator, + # scope: proc { siblings }, instance_scope: proc { favorite? } + # ``` + # Now, when this record is validated, if `record.favorite?` is `true`, + # `record` must pass the overlap check with its siblings. + # If `record.favorite?` is `false`, it is under less scrutiny. + # class NoOverlapValidator < ActiveModel::Validator def validate(record) overlapping_records = temporally_overlapping_for(record) instance_scope = if options[:instance_scope].is_a? Proc - record.instance_eval&options[:instance_scope] + record.instance_eval(&options[:instance_scope]) else true end - if overlapping_records.any? && instance_scope - - record.errors.add( - :base, - :no_overlap, - model_name: record.class.model_name.human, - model_name_plural: record.class.model_name.plural.humanize, - start_date: record.span.start_date, - end_date: record.span.end_date, - count: overlapping_records.size, - overlapping_records_s: overlapping_records.join(",") - ) - end + return unless overlapping_records.any? && instance_scope + + record.errors.add( + :base, + :no_overlap, + model_name: record.class.model_name.human, + model_name_plural: record.class.model_name.plural.humanize, + start_date: record.span.start_date, + end_date: record.span.end_date, + count: overlapping_records.size, + overlapping_records_s: overlapping_records.join(', ') + ) end - #TODO add back condition for start_date nil - #TODO add support for multiple spans (currently only checks :default) + # TODO: add back condition for start_date nil + # TODO: add support for multiple spans (currently only checks :default) def temporally_overlapping_for(record) scope = record.instance_eval(&options[:scope]) start_date = record.span.start_date || Date.current + end_date = record.span.end_date + end_field = record.span.end_field + arel_table = record.class.arel_table if end_date scope.where( arel_table[record.span.start_field].lteq(end_date) .and( - arel_table[record.span.end_field].gteq(start_date) - .or(arel_table[record.span.end_field].eq(nil)) + arel_table[end_field].gteq(start_date) + .or(arel_table[end_field].eq(nil)) ) ) else scope.where( - arel_table[record.span.end_field].gteq(start_date) - .or(arel_table[record.span.end_field].eq(nil)) + arel_table[end_field].gteq(start_date) + .or(arel_table[end_field].eq(nil)) ) end end diff --git a/spec/lib/no_overlap_validator_spec.rb b/spec/lib/no_overlap_validator_spec.rb index d0eeef1..3efa465 100644 --- a/spec/lib/no_overlap_validator_spec.rb +++ b/spec/lib/no_overlap_validator_spec.rb @@ -42,6 +42,29 @@ let(:all_siblings) { [brother, sister, brother_from_another_mother] } + describe 'instance_scope' do + let(:child_class) { OneParentChild } + + let(:new_child_start_date) { Date.current - 2 } + let(:new_child_end_date) { Date.current + 3 } + + before { new_child.favorite = favorite } + + context 'when instance_scope evaluates to false' do + let(:favorite) { false } + it 'skips validation on the record for which instance_scope is false' do + expect(new_child).to be_valid + end + end + + context 'when instance_scope evaluates to true' do + let(:favorite) { true } + it 'validates normally' do + expect(new_child).not_to be_valid + end + end + end + describe 'an object with a single parent' do let(:child_class) { OneParentChild } diff --git a/spec/spec_models.rb b/spec/spec_models.rb index 6b7c4c1..ad56760 100644 --- a/spec/spec_models.rb +++ b/spec/spec_models.rb @@ -28,14 +28,22 @@ t.date :start_date t.date :end_date + + # every one-parent child is a favorite! ...by default + t.boolean :favorite, default: true end acts_as_span + def favorite? + favorite + end + belongs_to :mama has_siblings through: [:mama] - validates_with ActsAsSpan::NoOverlapValidator, scope: proc { siblings } + validates_with ActsAsSpan::NoOverlapValidator, + scope: proc { siblings }, instance_scope: proc { favorite? } validates_with ActsAsSpan::WithinParentDateSpanValidator, parents: [:mama] end