Module: TimeRangeUniqueness::ModelAdditions

Defined in:
lib/time_range_uniqueness/model_additions.rb

Overview

The ‘ModelAdditions` module provides a custom validation for ensuring that time ranges in ActiveRecord models are unique across records, optionally scoped by other columns.

This module is extended onto ActiveRecord::Base so that models gain a validation method to check for overlapping time ranges between records.

Example

class Event < ApplicationRecord
  validates_time_range_uniqueness(
    with: :event_time_range,
    scope: :event_name,
    message: 'cannot overlap with an existing event'
  )
end

This example ensures that the ‘event_time_range` column in the `Event` model does not overlap with other records having the same `event_name`. If a new event’s time range overlaps, an error is added to the ‘event_time_range` field.

Options

  • :with - The name of the time range column (required).

  • :scope - (Optional) An array of columns to scope the uniqueness check (e.g., event name).

  • :message - (Optional) A custom error message when validation fails. Defaults to ‘overlaps with an existing record’ if not provided.

Methods

  • validates_time_range_uniqueness - Adds a validation for time range uniqueness.

  • ModelAdditions.overlapping? - Internal helper that checks for overlapping time ranges.

  • ModelAdditions.scoped_relation - Internal helper that builds the scoped relation.

Extending this onto ActiveRecord::Base adds the ability to ensure that the specified time range does not overlap with other records’ time ranges, optionally scoped by additional fields.

Defined Under Namespace

Modules: ViolationHandling

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.constraint_name_for(model, config) ⇒ Object



85
86
87
88
89
90
91
# File 'lib/time_range_uniqueness/model_additions.rb', line 85

def self.constraint_name_for(model, config)
  return config[:name].to_s if config[:name]

  TimeRangeUniqueness::ConstraintNaming.default_constraint_name(
    model.table_name, config[:scope_columns], config[:column]
  )
end

.matched_constraint(record, error) ⇒ Object



93
94
95
96
97
98
99
100
# File 'lib/time_range_uniqueness/model_additions.rb', line 93

def self.matched_constraint(record, error)
  name = violation_constraint_name(error)
  return unless name

  record.class.time_range_uniqueness_constraints.find do |config|
    constraint_name_for(record.class, config) == name
  end
end

.overlapping?(record, time_range_column, scope_columns) ⇒ Boolean

Checks whether the record’s time range overlaps any other record, optionally scoped.

Parameters:

  • record (ActiveRecord::Base)

    The record being validated.

  • time_range_column (Symbol)

    The name of the time range column.

  • scope_columns (Array<Symbol>)

    The columns to scope the uniqueness check.

Returns:

  • (Boolean)

    True if there is an overlap, false otherwise.



133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/time_range_uniqueness/model_additions.rb', line 133

def self.overlapping?(record, time_range_column, scope_columns)
  time_range = record.public_send(time_range_column)
  return false if time_range.nil?

  # A NULL scope value can never satisfy the exclusion constraint's `=` comparison,
  # so such a record can never conflict at the database level. Mirror that here.
  return false if scope_columns.any? { |col| record.public_send(col).nil? }

  column = record.class.connection.quote_column_name(time_range_column)
  bounds = time_range.exclude_end? ? '[)' : '[]'

  scoped_relation(record, scope_columns)
    .where("#{column} && tstzrange(?, ?, ?)", time_range.begin, time_range.end, bounds)
    .exists?
end

.register_constraint(model, time_range_column, scope_columns, message, name) ⇒ Object



66
67
68
69
70
71
72
73
74
75
# File 'lib/time_range_uniqueness/model_additions.rb', line 66

def self.register_constraint(model, time_range_column, scope_columns, message, name)
  unless model.respond_to?(:time_range_uniqueness_constraints)
    model.class_attribute :time_range_uniqueness_constraints, instance_accessor: false, default: []
    model.prepend(ViolationHandling)
  end

  model.time_range_uniqueness_constraints += [
    { column: time_range_column, scope_columns: scope_columns, message: message, name: name }
  ]
end

.scoped_relation(record, scope_columns) ⇒ ActiveRecord::Relation

Builds the set of other records to check against, optionally scoped by the given columns.

Parameters:

  • record (ActiveRecord::Base)

    The record being validated.

  • scope_columns (Array<Symbol>)

    The columns to scope the uniqueness check.

Returns:

  • (ActiveRecord::Relation)

    All other records, scoped by the given columns.



154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/time_range_uniqueness/model_additions.rb', line 154

def self.scoped_relation(record, scope_columns)
  klass = record.class
  # Pair each primary key column with its value so this works for both single
  # and composite primary keys (record.id is an array for composite keys).
  excluded = Array(klass.primary_key).zip(Array(record.id)).to_h
  relation = klass.where.not(excluded)

  scope_columns.each do |col|
    relation = relation.where(col => record.public_send(col))
  end

  relation
end

.violation_constraint_name(error) ⇒ Object



77
78
79
80
81
82
83
# File 'lib/time_range_uniqueness/model_additions.rb', line 77

def self.violation_constraint_name(error)
  cause = error.cause
  return nil unless defined?(PG::ExclusionViolation) && cause.is_a?(PG::ExclusionViolation)

  result = cause.respond_to?(:result) ? cause.result : nil
  result&.error_field(PG::Result::PG_DIAG_CONSTRAINT_NAME)
end

Instance Method Details

#validates_time_range_uniqueness(options = {}) ⇒ Object

Adds a custom validation method to ensure that the specified time range column is unique across all records, optionally scoped by other columns.

Raises an ArgumentError if the :with option is not specified.

Parameters:

  • options (Hash) (defaults to: {})

    The options for the validation.

Options Hash (options):

  • :with (Symbol)

    The name of the time range column.

  • :scope (Array<Symbol>) — default: Optional

    Columns to scope the uniqueness check.

  • :message (String) — default: Optional

    Custom error message when validation fails.

Raises:

  • (ArgumentError)


50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# File 'lib/time_range_uniqueness/model_additions.rb', line 50

def validates_time_range_uniqueness(options = {})
  raise ArgumentError, 'You must specify the :with option with the time range column name' unless options[:with]

  time_range_column = options[:with]
  scope_columns = Array(options[:scope])
  message = options[:message] || 'overlaps with an existing record'

  TimeRangeUniqueness::ModelAdditions.register_constraint(self, time_range_column, scope_columns, message,
                                                          options[:name])

  validate do
    overlapping = TimeRangeUniqueness::ModelAdditions.overlapping?(self, time_range_column, scope_columns)
    errors.add(time_range_column, message) if overlapping
  end
end