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.

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.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.



69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/time_range_uniqueness/model_additions.rb', line 69

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

.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.



90
91
92
93
94
95
96
97
98
99
100
101
102
# File 'lib/time_range_uniqueness/model_additions.rb', line 90

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

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
# 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'

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