validates_overlap 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 22d9351114cf974a1843daead1ff7fb57503951d2cfecd9bfe19c2041bd4dd01
4
- data.tar.gz: 95e4e59f48f05f22ad589a06e77224fd1d065414ddb22a0d68f17e23005c0168
3
+ metadata.gz: e58e04fbe91942b00130f68c9e8d838838039d5ba6dc5218e563cc34d2deaf06
4
+ data.tar.gz: 995aafe99900907fc2c43536969a6c3984026a29da87998b2c869371012ea84e
5
5
  SHA512:
6
- metadata.gz: 8483ac32c24e082f364de7e090dd01b6d14183657bbf6e002770b1d256904eed24e56fcf19707bfa586d79ca9be24b1b7e5e5f9d09de9f2f18d404cc0dec840b
7
- data.tar.gz: 4b2d9a55f399584ba014ca1633bfa4e278ad42a89db9ff5db4828da9c71d1bba731ceb9394ee5cb9903f2ffa51c76f542efea40037a4e99e7ed6bcea2498f73d
6
+ metadata.gz: dbd9aaf4d1a31d36d27222413f83d97b5cf9658b805b5de0b4a61fae7dcbc7e57833e82419b6144a9cb0a8097160167dbbf8202bb58314ac2ac537d23455b361
7
+ data.tar.gz: b7da9ddfb4e78fb8821af1a5d3866cda098e5f7b14f2af3506aef1b7ea67390e16f3b59c5987d25b83513bf52902473e1b2b5b6673f1d074bfa72dc0b63640ff
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # ValidatesOverlap 1.x Change Log
2
2
 
3
+ ## 1.3.0 (2026-08-15)
4
+
5
+ RSpec tests: **126 → 243** (+117 tests)
6
+
7
+ ### New Features
8
+
9
+ - support for native PostgreSQL range columns: declare the validation with a single range-column attribute (`validates :period, overlap: { scope: :user_id }` on a `tsrange` / `tstzrange` / `daterange` / `int4range` / `int8range` / `numrange` column) — compared with PostgreSQL's `&&` operator, whose range algebra decides every edge case, so the validation and an exclusion constraint can never disagree: bound inclusivity comes from the stored value, a `NULL` range conflicts with nothing, `'(,)'` conflicts with everything. `exclude_edges` and the shifts raise `ArgumentError` for range columns; a single-attribute validation on a non-range column raises `OverlapValidator::UnsupportedColumnType`, and so does a two-attribute validation on range columns (a range column is validated on its own). `add_overlap_constraint :meetings, :period, scope: :user_id` generates the matching one-column exclusion constraint
10
+ - `record.overlapping_records`, defined on every model with an overlap validation: freshly queries the conflicting records on demand and returns an `ActiveRecord::Relation` — always current, and no records are loaded until the result is used. With several overlap validations, a nil range value's empty result no longer hides the other validations' conflicts (Rails 6.1/7.0), combining validations that query different models raises a clear `ArgumentError` instead of silently merging them, and a model whose validators were removed still returns an empty relation
11
+ - `add_overlap_constraint` / `remove_overlap_constraint` migration helpers (PostgreSQL): generate a database-level exclusion constraint that closes the check-then-act race no validation can close — the range type is inferred from the column types, scope columns are compared with equality, and the edge semantics mirror the validator's; raises `NotImplementedError` on other adapters. Reversible in `def change` migrations on Rails 7.1+ (`db:rollback` drops the constraint); `btree_gist` is only enabled when missing; warns when a scope column allows NULL — NULL-scoped rows are not restricted by an exclusion constraint (`NULL = NULL` is not true in SQL), while the validation does match NULL scope values. On PostgreSQL 18+, `without_overlaps: true` generates the standard-SQL temporal unique constraint (`UNIQUE (scope, range WITHOUT OVERLAPS)`) for a scoped range column — PostgreSQL enforces it as an exclusion constraint, so `RescueExclusionViolation` works unchanged; note that it rejects empty range values (suggested by [Chedli Bourguiba](https://github.com/chaadow) in [PR #64](https://github.com/tilo/validates_overlap/pull/64))
12
+ - `ValidatesOverlap::RescueExclusionViolation` (opt-in model concern): turns the constraint violation from the race window into a normal validation failure — `save` returns false with the overlap error set, `save!` raises `ActiveRecord::RecordInvalid`. The containment savepoint is only opened when the caller already has a transaction open on PostgreSQL — a halted callback (`throw :abort`) rolls its earlier writes back again, and saves on other adapters pay no savepoint round-trips; the error lands on exactly the keys the validator would use (array `message_title`, `:base` fallback for association attributes); Rails 8.1's `ActiveRecord::ExclusionViolation` is recognized even when the error's cause chain is lost (e.g. JRuby)
13
+ - the test suite runs against SQLite, PostgreSQL (`DB=postgres`, including the PostgreSQL-only specs in `spec_pg/`), and MySQL (`DB=mysql`), with CI jobs for all three adapters and an allowed-failure lane against rails main; new rake tasks run them locally (`rake spec:postgres` / `spec:mysql` / `spec:all`)
14
+
15
+ ### Deprecations
16
+
17
+ - `load_overlapped: true` is deprecated, removal in 2.0 — it wrote `@overlapped_records` into the record from the outside, kept stale results after re-validation, and loaded the records during every validation; use `record.overlapping_records` instead
18
+
3
19
  ## 1.2.0 (2026-08-11)
4
20
 
5
21
  RSpec tests: **85 → 126** (+41 tests)
data/CONTRIBUTORS.md CHANGED
@@ -1,4 +1,4 @@
1
- # A Big Thank You to all 21 Contributors!!
1
+ # A Big Thank You to all 22 Contributors!!
2
2
 
3
3
  `validates_overlap` was created in 2011 by [Robin Bortlik](https://github.com/robinbortlik), who designed it and maintained it, reviewing and merging every pull request below along the way. Thank you, Robin, for this gem and all the work you put into it! ❤️
4
4
 
@@ -24,3 +24,4 @@ A Big Thank you to everyone who filed issues, sent comments, and who contributed
24
24
  * [Nujian Den Mark Meralpis](https://github.com/denmarkmeralpis)
25
25
  * [ohenrik](https://github.com/ohenrik)
26
26
  * [Jorge Santos](https://github.com/jsantos)
27
+ * [Chedli Bourguiba](https://github.com/chaadow)
data/README.md CHANGED
@@ -4,55 +4,11 @@
4
4
 
5
5
  `validates_overlap` provides an ActiveRecord validator for resources that must not overlap, e.g. in datetime. Think rentals, meetings, bookings, work shifts, or assignments where the same resource cannot be assigned to multiple people or entities during overlapping time periods. But it also works for other domains than datetime (see below).
6
6
 
7
- You specify two attributes defining a datetime range, such as `starts_at` and `ends_at`, and the validator checks with a single SQL query whether another record overlaps that range no records are loaded for the comparison. If one does, the record receives a normal validation error.
7
+ You specify the attributes defining a datetime range — typically two, such as `starts_at` and `ends_at`, or on PostgreSQL a single native range column — and the validator checks with a single SQL query whether another record overlaps that range; no records are loaded for the comparison. If one does, the record receives a normal validation error.
8
8
 
9
9
  It also supports scoped validation (per user, room, resource, etc.), open-ended ranges (a nil start or end counts as extending forever), ranges that may touch at their boundaries (`exclude_edges`), required gaps between ranges or a tolerated amount of overlap (`start_shift` / `end_shift`), associations, and retrieving the conflicting records.
10
10
 
11
- The range columns don't have to be dates or times: any linearly orderable column type works, such as integer ranges (ticket number blocks), decimal ranges (price bands), or string ranges (alphabetical partitions).
12
-
13
- ## Note: Other Domains
14
-
15
- Other domains / types can be checked for overlap, as long as they can be compared linearly.
16
- e.g. The overlap check runs on plain SQL comparisons, so any linearly orderable column type works — for example integer ranges (no two records may claim overlapping number blocks), decimal ranges (price bands), or string ranges (alphabetical partitions). A nil endpoint means the range is open-ended on that side, for these types too, and the shifts work for numeric ranges as well (e.g. an integer gap or overlap tolerance). The test suite covers `date`, `datetime`, `timestamp`, `integer`, `decimal`, and `string` range columns.
17
-
18
- ## ⚠️ Note: Cyclic Domains can NOT be validated for overlap
19
-
20
- Overlap validation requires a linear domain: every range must satisfy `start <= end`. On a cyclic (wrap-around) domain, like time, every pair of values denotes *some* valid range (`11:00..10:00` is simply the 23-hour complement of `10:00..11:00`), so a wraparound range is indistinguishable from accidentally swapped fields — no validation can tell intent from typo. This is a mathematical property of circular domains, not an implementation gap.
21
-
22
- The validator therefore refuses `:time` range columns and raises `OverlapValidator::UnsupportedColumnType` — use datetime columns instead, or split windows that cross midnight into two records.
23
-
24
- But cyclicity is a property of the domain, not the column type — ⚠️ user-encoded cyclic domains hide inside perfectly linear columns, where no guard can see them:
25
-
26
- - day-of-week as integer (0..6): a Friday-to-Monday shift range `5..1` wraps — same pathology as `22:00..02:00`, stored in an innocent `:integer` column
27
- - month numbers (1..12): a November-to-February season range `11..2`
28
- - ISO week numbers: a range from week 52 to week 2 across New Year
29
- - angles / compass headings (0..360): a heading sector `350..10`
30
- - longitude (−180°..+180°)
31
- - hour-of-day as integer — people re-implement `:time` in an int column all the time
32
- - time-of-day (24-hour clock values without a date component)
33
-
34
- If your domain is cyclic, the validator will silently give wrong answers for wrapping ranges. Restructure the data instead: split wrapping ranges into two linear records, or lift the values into a linear domain (e.g. datetime instead of time-of-day).
35
-
36
- To catch inverted ranges loudly instead of silently (for any column type), pair the overlap validation with an order check on your model, e.g. `validates :ends_at, comparison: { greater_than: :starts_at }`.
37
-
38
- ## Ruby / Rails Compatibility
39
-
40
- Every combination below is verified on every push by the [CI matrix](https://github.com/tilo/validates_overlap/actions):
41
-
42
- | Rails | Tested with Ruby |
43
- |-------|--------------------|
44
- | 8.1 | 3.2, 3.3, 3.4 |
45
- | 8.0 | 3.2, 3.3, 3.4 |
46
- | 7.2 | 3.1, 3.2, 3.3, 3.4 |
47
- | 7.1 | 3.0, 3.1, 3.2, 3.3 |
48
- | 7.0 | 3.0, 3.1, 3.2 |
49
- | 6.1 | 3.0 |
50
-
51
- The gemspec requires `activerecord >= 6.0`. Rails 6.0 is not part of the test matrix, but no incompatibilities are known. The previous version 0.8.6 was compatible with Rails 3, 4, and 5.
52
-
53
- Note for MySQL users: use `DATETIME` (not `TIMESTAMP`) columns for your range attributes — MySQL's `TIMESTAMP` type cannot store dates after January 2038, which matters for long-running or far-future ranges. PostgreSQL and SQLite date/time types have no such limit.
54
-
55
- ## Usage
11
+ ## Quick Start
56
12
 
57
13
  Add to your gemfile
58
14
 
@@ -62,98 +18,67 @@ gem 'validates_overlap'
62
18
 
63
19
  In your model
64
20
 
65
- #### without scope
66
-
67
21
  ```ruby
68
22
  validates :starts_at, :ends_at, :overlap => true
69
- ```
70
-
71
- #### with scope
72
23
 
73
- ```ruby
24
+ # or scoped, e.g. per user:
74
25
  validates :starts_at, :ends_at, :overlap => {:scope => "user_id"}
75
26
  ```
76
27
 
77
- #### exclude edge(s)
78
-
79
- ```ruby
80
- validates :starts_at, :ends_at, :overlap => {:exclude_edges => "starts_at"}
81
- validates :starts_at, :ends_at, :overlap => {:exclude_edges => ["starts_at", "ends_at"]}
82
- ```
28
+ All options — scopes, edge handling, gaps and tolerated overlap, custom messages, associations, retrieving the conflicting records — are described in the [Option Reference](docs/options.md).
83
29
 
84
- #### shift edges
30
+ ## Documentation
85
31
 
86
- The shifts move the record's own range edges before the overlap check, so you can require a gap between records — or tolerate a bounded overlap:
32
+ * [Examples and Introduction](docs/_introduction.md)
33
+ * [Option Reference](docs/options.md)
34
+ * [Range Types and Domains](docs/range_types.md)
35
+ * [PostgreSQL: Exclusion Constraints](docs/postgresql.md)
87
36
 
88
- ```ruby
89
- # widen the range: records must be at least 1 day apart (gap enforced)
90
- validates :starts_at, :ends_at, :overlap => {:start_shift => -1.day, :end_shift => 1.day}
37
+ ## Range Types
91
38
 
92
- # shrink the range: up to 2 days of overlap are accepted
93
- validates :starts_at, :ends_at, :overlap => {:start_shift => 2.days, :end_shift => -2.days}
94
- ```
39
+ The range columns don't have to be dates or times: any linearly orderable column type works, such as integer ranges (ticket number blocks), decimal ranges (price bands), or string ranges (alphabetical partitions). ⚠️ Cyclic (wrap-around) domains — time-of-day, day-of-week, month numbers, angles — can NOT be validated for overlap; the validator refuses `:time` columns outright. [Range Types and Domains](docs/range_types.md) explains both halves.
95
40
 
96
- #### non-date ranges
41
+ ## ⚠️ Concurrent Writes
97
42
 
98
- The overlap check runs on plain SQL comparisons, so any orderable column type works — for example integer ranges (no two records may claim overlapping number blocks), decimal ranges (price bands), or string ranges (alphabetical partitions). A nil endpoint means the range is open-ended on that side, for these types too, and the shifts work for numeric ranges as well (e.g. an integer gap or overlap tolerance). The test suite covers `date`, `datetime`, `timestamp`, `integer`, `decimal`, and `string` range columns.
43
+ Validation alone can not prevent double-booking under concurrent writes: two simultaneous requests can both pass the check and both save the same limitation as `validates_uniqueness_of`. On PostgreSQL, the gem's migration helpers generate an exclusion constraint that closes this race at the database level:
99
44
 
100
45
  ```ruby
101
- class TicketBlock < ActiveRecord::Base
102
- validates :number_start, :number_end, :overlap => true
103
- end
104
-
105
- TicketBlock.create!(number_start: 100, number_end: 199)
106
- TicketBlock.new(number_start: 150, number_end: 250).valid? # => false (overlaps)
107
- TicketBlock.new(number_start: 150, number_end: nil).valid? # => false (open-ended, overlaps)
108
- TicketBlock.new(number_start: 200, number_end: 299).valid? # => true
46
+ add_overlap_constraint :meetings, :starts_at, :ends_at, scope: :user_id
109
47
  ```
110
48
 
111
- #### define custom validation key(s) and message
49
+ ## PostgreSQL Support
50
+ Native PostgreSQL range columns are supported as well — declare the validation with the single range attribute (`validates :period, overlap: ...` on a `tstzrange` column). On PostgreSQL 18+, the migration helpers can also generate the standard-SQL temporal unique constraint (`without_overlaps: true`). See [PostgreSQL: Exclusion Constraints](docs/postgresql.md) for the helpers, the range-column semantics, the companion concern that turns the constraint violation into a normal validation error, and the equivalent hand-written SQL. CI runs the PostgreSQL suite against versions 16 and 18.
112
51
 
113
- ```ruby
114
- validates :starts_at, :ends_at, :overlap => {:message_title => "Some validation title", :message_content => "Some validation message"}
115
- validates :starts_at, :ends_at, :overlap => {:message_title => [:start_at, :end_at], :message_content => "Some validation message"}
116
- ```
52
+ ## Note: Add an index — the overlap check runs on every save
117
53
 
118
- #### with complicated relations
119
-
120
- Example describes validation of user, positions and time slots.
121
- User can't be assigned 2 times on position which is under time slot with time overlap.
54
+ The validation runs one `EXISTS` query per save. Without a suitable index that query is a full table scan — invisible at 1,000 rows, painful at 1,000,000. Add a composite index with your scope columns first, then the range columns:
122
55
 
123
56
  ```ruby
124
- class Position < ActiveRecord::Base
125
- belongs_to :time_slot
126
- belongs_to :user
127
- validates "time_slots.starts_at", "time_slots.ends_at",
128
- :overlap => {
129
- :query_options => {:joins => :time_slot},
130
- :scope => { "positions.user_id" => proc{|position| position.user_id} }
131
- }
132
- end
57
+ add_index :meetings, [:user_id, :starts_at, :ends_at]
133
58
  ```
134
59
 
135
- #### apply named scopes
60
+ For unscoped validation, index the range columns alone (`[:starts_at, :ends_at]`). If you added the PostgreSQL exclusion constraint, you already have a suitable index — the constraint is backed by a GiST index that serves overlap queries. On large tables, verify with `EXPLAIN` that the query actually uses your index.
136
61
 
137
- ```ruby
138
- class ActiveMeeting < ActiveRecord::Base
139
- validates :starts_at, :ends_at, :overlap => {:query_options => {:active => nil}}
140
- scope :active, where(:is_active => true)
141
- end
142
- ```
62
+ ## Ruby / Rails Compatibility
143
63
 
144
- #### Overlapped records
145
- If you need to know what records are in conflict, pass the `{load_overlapped: true }` as validator option and validator will set instance variable `@overlapped_records` to the validated object.
64
+ Every combination below is verified on every push by the [CI matrix](https://github.com/tilo/validates_overlap/actions):
146
65
 
147
- ```ruby
148
- class ActiveMeeting < ActiveRecord::Base
149
- validates :starts_at, :ends_at, :overlap => {:load_overlapped => true}
66
+ | Rails | Tested with Ruby |
67
+ |-------|--------------------|
68
+ | 8.1 | 3.2, 3.3, 3.4 |
69
+ | 8.0 | 3.2, 3.3, 3.4 |
70
+ | 7.2 | 3.1, 3.2, 3.3, 3.4 |
71
+ | 7.1 | 3.0, 3.1, 3.2, 3.3 |
72
+ | 7.0 | 3.0, 3.1, 3.2 |
73
+ | 6.1 | 3.0 |
150
74
 
151
- def overlapped_records
152
- @overlapped_records || []
153
- end
154
- end
75
+ The gemspec requires `activerecord >= 6.0`. Rails 6.0 is not part of the test matrix, but no incompatibilities are known. The previous version 0.8.6 was compatible with Rails 3, 4, and 5.
155
76
 
156
- ```
77
+ Note for MySQL users: use `DATETIME` (not `TIMESTAMP`) columns for your range attributes — MySQL's `TIMESTAMP` type cannot store dates after January 2038, which matters for long-running or far-future ranges. PostgreSQL and SQLite date/time types have no such limit.
78
+
79
+ ## Contributing
80
+
81
+ Bug reports and pull requests are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for how to run the test suite against all three database adapters (SQLite, PostgreSQL, MySQL).
157
82
 
158
83
  ## Maintainership
159
84
 
@@ -161,5 +86,3 @@ end
161
86
  Since August 2026 the gem is maintained by [Tilo Sloboda](https://github.com/tilo).
162
87
 
163
88
  A big thank you to Robin for creating this awesome gem and for the years of work he put into it. ❤️
164
-
165
-
@@ -0,0 +1,146 @@
1
+ module ValidatesOverlap
2
+ # Migration helpers for a database-level overlap guarantee (PostgreSQL only).
3
+ # The validation is check-then-act and cannot prevent double-booking under
4
+ # concurrent writes; an exclusion constraint can (see the README).
5
+ #
6
+ # == Example:
7
+ # def up
8
+ # add_overlap_constraint :meetings, :starts_at, :ends_at, scope: :user_id
9
+ # end
10
+ # def down
11
+ # remove_overlap_constraint :meetings
12
+ # end
13
+ module MigrationHelpers
14
+ # Two scalar columns: add_overlap_constraint :meetings, :starts_at, :ends_at, scope: :user_id
15
+ # One range column: add_overlap_constraint :meetings, :period, scope: :user_id
16
+ #
17
+ # scope: column(s) compared with equality, mirroring the validator's :scope
18
+ # name: constraint name (default: <table>_no_overlap)
19
+ # range_type: PostgreSQL range type; inferred from the column types when omitted
20
+ # exclude_edges: false (default) matches the validator's default — touching edges
21
+ # conflict; true builds half-open ranges, matching the validator's
22
+ # exclude_edges: [start, end] where touching is allowed.
23
+ # Not applicable to a range column (bounds live in the value)
24
+ # without_overlaps: PostgreSQL 18+, single range column with scope only — generate
25
+ # the standard-SQL temporal unique constraint
26
+ # UNIQUE (scope, range WITHOUT OVERLAPS) instead of the EXCLUDE clause
27
+ def add_overlap_constraint(table, starts_at, ends_at = nil, scope: [], name: nil, range_type: nil, exclude_edges: false, without_overlaps: false)
28
+ assert_postgresql!('add_overlap_constraint')
29
+ scope_columns = Array(scope)
30
+ warn_nullable_scope_columns(table, scope_columns)
31
+ enable_extension 'btree_gist' if !scope_columns.empty? && !connection.extension_enabled?('btree_gist')
32
+ return add_without_overlaps_constraint(table, starts_at, ends_at, scope_columns, name, exclude_edges) if without_overlaps
33
+ elements = scope_columns.map { |column| "#{connection.quote_column_name(column)} WITH =" }
34
+ if ends_at.nil?
35
+ raise ArgumentError, 'validates_overlap: exclude_edges is not applicable to a range column — bound inclusivity is part of the range value itself' if exclude_edges
36
+ assert_range_column!(table, starts_at)
37
+ elements << "#{connection.quote_column_name(starts_at)} WITH &&"
38
+ else
39
+ range_type ||= overlap_range_type(table, starts_at, ends_at)
40
+ bounds = exclude_edges ? '[)' : '[]'
41
+ elements << "#{range_type}(#{connection.quote_column_name(starts_at)}, #{connection.quote_column_name(ends_at)}, '#{bounds}') WITH &&"
42
+ end
43
+ if connection.respond_to?(:add_exclusion_constraint)
44
+ # Rails 7.1+: the recorded command is invertible, so the helper works in def change
45
+ add_exclusion_constraint table, elements.join(', '), using: :gist, name: overlap_constraint_name(table, name)
46
+ else
47
+ execute <<~SQL
48
+ ALTER TABLE #{connection.quote_table_name(table)}
49
+ ADD CONSTRAINT #{connection.quote_column_name(overlap_constraint_name(table, name))}
50
+ EXCLUDE USING gist (#{elements.join(', ')})
51
+ SQL
52
+ end
53
+ end
54
+
55
+ # A plain DROP CONSTRAINT on purpose: it drops both constraint forms, while
56
+ # Rails' remove_exclusion_constraint refuses a temporal unique constraint
57
+ # (without_overlaps) — and delegating would not make removal invertible in
58
+ # def change anyway, since the inversion needs the original expression
59
+ def remove_overlap_constraint(table, name: nil)
60
+ assert_postgresql!('remove_overlap_constraint')
61
+ execute "ALTER TABLE #{connection.quote_table_name(table)} DROP CONSTRAINT #{connection.quote_column_name(overlap_constraint_name(table, name))}"
62
+ end
63
+
64
+ private
65
+
66
+ # PostgreSQL 18's temporal unique constraint. PostgreSQL enforces it as an
67
+ # exclusion constraint — UNIQUE (id, r WITHOUT OVERLAPS) behaves like
68
+ # EXCLUDE USING GIST (id WITH =, r WITH &&) and violations raise
69
+ # PG::ExclusionViolation — so RescueExclusionViolation keeps working.
70
+ # Differences to the EXCLUDE form: standard SQL syntax, but empty ranges are
71
+ # rejected by the database, and the raw SQL here cannot be inverted by
72
+ # Rails — use def up / def down with this option
73
+ def add_without_overlaps_constraint(table, range_column, ends_at, scope_columns, name, exclude_edges)
74
+ raise ArgumentError, 'validates_overlap: without_overlaps applies to a single range column — the two-column form builds a range expression, which PostgreSQL does not allow in a temporal constraint' unless ends_at.nil?
75
+ raise ArgumentError, 'validates_overlap: exclude_edges is not applicable to a range column — bound inclusivity is part of the range value itself' if exclude_edges
76
+ raise ArgumentError, 'validates_overlap: without_overlaps needs at least one scope column — PostgreSQL requires an ordinary column before WITHOUT OVERLAPS' if scope_columns.empty?
77
+ raise ArgumentError, "validates_overlap: without_overlaps requires PostgreSQL 18+ (the server reports version #{connection.database_version})" if connection.database_version < 180_000
78
+ assert_range_column!(table, range_column)
79
+ columns = scope_columns.map { |column| connection.quote_column_name(column) } + ["#{connection.quote_column_name(range_column)} WITHOUT OVERLAPS"]
80
+ execute <<~SQL
81
+ ALTER TABLE #{connection.quote_table_name(table)}
82
+ ADD CONSTRAINT #{connection.quote_column_name(overlap_constraint_name(table, name))}
83
+ UNIQUE (#{columns.join(', ')})
84
+ SQL
85
+ end
86
+
87
+ # NULL = NULL is not true in SQL, so the constraint never restricts rows whose
88
+ # scope value is NULL — while the validator DOES match NULL scope values against
89
+ # each other (see docs/postgresql.md). Warn so the gap is a choice, not a surprise.
90
+ def warn_nullable_scope_columns(table, scope_columns)
91
+ return if scope_columns.empty?
92
+ columns = connection.columns(table).index_by(&:name)
93
+ scope_columns.each do |scope_column|
94
+ column = columns[scope_column.to_s]
95
+ next unless column&.null
96
+ say "validates_overlap: scope column #{scope_column} on #{table} allows NULL — rows with a NULL #{scope_column} are NOT restricted by this constraint (NULL = NULL is not true in SQL), while the overlap validation does match NULL scope values against each other. Consider a NOT NULL constraint on #{scope_column}."
97
+ end
98
+ end
99
+
100
+ def assert_range_column!(table, column_name)
101
+ column = connection.columns(table).find { |col| col.name == column_name.to_s }
102
+ raise ArgumentError, "validates_overlap: no column #{column_name} on #{table}" unless column
103
+ return if OverlapValidator::RANGE_COLUMN_TYPES.include?(column.type)
104
+ raise ArgumentError, "validates_overlap: #{column_name} on #{table} is #{column.type.inspect}, not a range column — pass two columns for scalar range endpoints"
105
+ end
106
+
107
+ def overlap_constraint_name(table, name)
108
+ name || "#{table}_no_overlap"
109
+ end
110
+
111
+ def assert_postgresql!(method_name)
112
+ return if connection.adapter_name.match?(/postgresql/i)
113
+ raise NotImplementedError, "validates_overlap: #{method_name} requires PostgreSQL (exclusion constraints are not available on #{connection.adapter_name})"
114
+ end
115
+
116
+ def overlap_range_type(table, starts_at, ends_at)
117
+ columns = connection.columns(table).index_by(&:name)
118
+ range_types = [starts_at, ends_at].map do |attr|
119
+ column = columns[attr.to_s]
120
+ raise ArgumentError, "validates_overlap: no column #{attr} on #{table}" unless column
121
+ range_type_for(column)
122
+ end.uniq
123
+ raise ArgumentError, "validates_overlap: #{starts_at} and #{ends_at} on #{table} have different types; pass range_type: explicitly" if range_types.size > 1
124
+ range_types.first
125
+ end
126
+
127
+ def range_type_for(column)
128
+ case column.type
129
+ when :datetime, :timestamp
130
+ column.sql_type.match?(/with time zone/) ? 'tstzrange' : 'tsrange'
131
+ when :date
132
+ 'daterange'
133
+ when :integer
134
+ column.limit == 8 ? 'int8range' : 'int4range'
135
+ when :decimal
136
+ 'numrange'
137
+ else
138
+ raise ArgumentError, "validates_overlap: cannot infer a range type for #{column.name} (#{column.type}); pass range_type: explicitly"
139
+ end
140
+ end
141
+ end
142
+ end
143
+
144
+ ActiveSupport.on_load(:active_record) do
145
+ ActiveRecord::Migration.include(ValidatesOverlap::MigrationHelpers)
146
+ end
@@ -7,10 +7,29 @@ class OverlapValidator < ActiveModel::EachValidator
7
7
  # operate on (e.g. :time — a cyclic domain, see README)
8
8
  class UnsupportedColumnType < ArgumentError; end
9
9
 
10
+ # PostgreSQL range column types usable with the single-attribute form
11
+ RANGE_COLUMN_TYPES = %i[tsrange tstzrange daterange int4range int8range numrange].freeze
12
+
10
13
  def initialize(args)
11
14
  attributes_are_range(args[:attributes])
15
+ reject_range_column_options(args)
16
+ model_class = args[:class]
12
17
 
13
18
  super
19
+
20
+ # defines record.overlapping_records on the validated model
21
+ model_class.include(ValidatesOverlap::OverlappingRecords) if model_class
22
+ if options[:load_overlapped]
23
+ ValidatesOverlap.deprecator.warn('load_overlapped is deprecated and will be removed in validates_overlap 2.0 — use record.overlapping_records instead')
24
+ end
25
+ end
26
+
27
+ # Build and return the overlap query for the given record — used by
28
+ # ValidatesOverlap::OverlappingRecords#overlapping_records
29
+ def overlapping_records_for(record)
30
+ reject_unsupported_column_types(record)
31
+ relation, sql_conditions, sql_values = initialize_query(record, options)
32
+ get_overlapped(relation, sql_conditions, sql_values)
14
33
  end
15
34
 
16
35
  # NOTE: Rails registers ONE validator instance per model class, shared by every
@@ -24,17 +43,24 @@ class OverlapValidator < ActiveModel::EachValidator
24
43
  record.instance_variable_set(:@overlapped_records, get_overlapped(relation, sql_conditions, sql_values))
25
44
  end
26
45
 
27
- if record.respond_to? attributes.first
28
- if options[:message_title].is_a?(Array)
29
- options[:message_title].each do |key|
30
- record.errors.add(key, options[:message_content] || :overlap)
31
- end
32
- else
33
- record.errors.add(options[:message_title] || attributes.first, options[:message_content] || :overlap)
46
+ add_overlap_error(record)
47
+ end
48
+ end
49
+
50
+ # Add this validation's configured error to the record. Also used by
51
+ # ValidatesOverlap::RescueExclusionViolation so a constraint violation caught
52
+ # in the race window lands on exactly the same keys as a validator-caught one
53
+ def add_overlap_error(record)
54
+ if record.respond_to? attributes.first
55
+ if options[:message_title].is_a?(Array)
56
+ options[:message_title].each do |key|
57
+ record.errors.add(key, options[:message_content] || :overlap)
34
58
  end
35
59
  else
36
- record.errors.add(options[:message_title] || :base, options[:message_content] || :overlap)
60
+ record.errors.add(options[:message_title] || attributes.first, options[:message_content] || :overlap)
37
61
  end
62
+ else
63
+ record.errors.add(options[:message_title] || :base, options[:message_content] || :overlap)
38
64
  end
39
65
  end
40
66
 
@@ -47,34 +73,67 @@ class OverlapValidator < ActiveModel::EachValidator
47
73
  # metadata must not be touched while migrations may still be pending.
48
74
  def reject_unsupported_column_types(record)
49
75
  return unless record.class.respond_to?(:columns_hash)
76
+ if range_column_mode?
77
+ attr = attributes.first
78
+ column = record.class.columns_hash[attr.to_s]
79
+ return if column && RANGE_COLUMN_TYPES.include?(column.type)
80
+ found = column ? column.type.inspect : 'no column at all'
81
+ raise UnsupportedColumnType, "#{record.class.name}##{attr}: a single-attribute overlap validation requires a PostgreSQL range column (#{RANGE_COLUMN_TYPES.join(', ')}), but #{attr} is #{found} — declare two attributes for scalar range endpoints"
82
+ end
50
83
  attributes.each do |attr|
51
84
  next if attr.to_s.include?('.')
52
85
  column = record.class.columns_hash[attr.to_s]
53
- next unless column && column.type == :time
86
+ next unless column
87
+ if RANGE_COLUMN_TYPES.include?(column.type)
88
+ raise UnsupportedColumnType, "#{record.class.name}##{attr} is a #{column.type} range column — a range column is validated on its own (validates :#{attr}, overlap: ...); a two-attribute validation takes scalar range endpoints"
89
+ end
90
+ next unless column.type == :time
54
91
  raise UnsupportedColumnType, "#{record.class.name}##{attr} is a :time column; time-of-day is a cyclic domain and cannot be validated for overlap — use datetime columns, or split ranges that cross midnight (see README)"
55
92
  end
56
93
  end
57
94
 
58
- # Build the complete overlap query for this record.
95
+ # One attribute = a native range column, compared with PostgreSQL's && operator
96
+ def range_column_mode?
97
+ attributes.size == 1
98
+ end
99
+
100
+ # Edge inclusivity and shifts live in the range value itself, so these
101
+ # options have nothing to act on — refuse loudly instead of ignoring
102
+ def reject_range_column_options(args)
103
+ return unless args[:attributes].size == 1
104
+ invalid = [:exclude_edges, :start_shift, :end_shift].select { |key| args[key] }
105
+ return if invalid.empty?
106
+ raise ArgumentError, "validates_overlap: #{invalid.join(', ')} not applicable to a range column — edge inclusivity and shifts are part of the range value itself"
107
+ end
108
+
109
+ # Build the complete overlap query for this record. Range-column mode builds
110
+ # the whole query on the relation (sql_conditions/sql_values stay nil); the
111
+ # two-attribute mode carries its conditions as a SQL string with bind values.
59
112
  # return array in form [relation, sql_conditions, sql_values]
60
113
  def initialize_query(record, options = {})
61
114
  scoped_model = options[:scoped_model].present? ? options[:scoped_model].constantize : record.class
62
115
  relation = scoped_model.default_scoped
63
- sql_values = generate_overlap_sql_values(record)
64
- sql_conditions, primary_key_values = generate_overlap_sql_conditions(record, sql_values)
65
- sql_values = sql_values.merge(primary_key_values)
66
- sql_conditions, sql_values = add_attributes(record, options[:scope], sql_conditions, sql_values) if options && options[:scope].present?
116
+ if range_column_mode?
117
+ relation = range_column_relation(relation, record)
118
+ relation = add_scope_to_relation(record, options[:scope], relation) if options && options[:scope].present?
119
+ sql_conditions = sql_values = nil
120
+ else
121
+ sql_values = generate_overlap_sql_values(record)
122
+ sql_conditions, primary_key_values = generate_overlap_sql_conditions(record, sql_values)
123
+ sql_values = sql_values.merge(primary_key_values)
124
+ sql_conditions, sql_values = add_attributes(record, options[:scope], sql_conditions, sql_values) if options && options[:scope].present?
125
+ end
67
126
  relation = add_query_options(relation, options[:query_options]) if options && options[:query_options].present?
68
127
  [relation, sql_conditions, sql_values]
69
128
  end
70
129
 
71
130
  # Check if exists at least one record in DB which is overlapped with current record
72
131
  def overlapped_exists?(relation, sql_conditions, sql_values)
73
- relation.exists?([sql_conditions, sql_values])
132
+ sql_conditions ? relation.exists?([sql_conditions, sql_values]) : relation.exists?
74
133
  end
75
134
 
76
135
  def get_overlapped(relation, sql_conditions, sql_values)
77
- relation.where([sql_conditions, sql_values])
136
+ sql_conditions ? relation.where([sql_conditions, sql_values]) : relation
78
137
  end
79
138
 
80
139
  # Resolve attributes values from record to use in sql conditions
@@ -115,9 +174,44 @@ class OverlapValidator < ActiveModel::EachValidator
115
174
  record.class.table_name
116
175
  end
117
176
 
118
- # Check if the validation of time range is defined by 2 attributes
177
+ # A range is defined by 2 scalar attributes, or by 1 range-column attribute
119
178
  def attributes_are_range(attributes)
120
- fail 'Validation of time range must be defined by 2 attributes' unless attributes.size == 2
179
+ fail 'Validation of time range must be defined by 1 or 2 attributes' unless [1, 2].include?(attributes.size)
180
+ end
181
+
182
+ # Native range column: PostgreSQL's own && operator does the comparison, and
183
+ # its range algebra decides every edge case — NULL overlaps nothing, 'empty'
184
+ # overlaps nothing, '(,)' overlaps everything, bound inclusivity comes from
185
+ # the stored value. The value is bound through the column's type via Arel,
186
+ # and a persisted record excludes itself with where.not on the primary key.
187
+ # return the relation with the overlap comparison applied
188
+ def range_column_relation(relation, record)
189
+ attr = attributes.first
190
+ value = record.send(attr)
191
+ return relation.none if value.nil?
192
+ arel_attribute = record.class.arel_table[attr]
193
+ relation = relation.where(Arel::Nodes::InfixOperation.new('&&', arel_attribute, Arel::Nodes.build_quoted(value, arel_attribute)))
194
+ return relation if record.new_record?
195
+ relation.where.not(record.class.primary_key => record.send(record.class.primary_key))
196
+ end
197
+
198
+ # Range mode adds scope conditions to the relation itself (two-attribute mode
199
+ # appends them to its SQL string via add_attributes): a nil value becomes
200
+ # IS NULL, an Array becomes IN, and procs / enum names resolve as everywhere
201
+ def add_scope_to_relation(record, attrs, relation)
202
+ pairs = attrs.is_a?(Hash) ? attrs.to_a : Array(attrs).map { |attr_name| [attr_name, nil] }
203
+ pairs.each do |attr_name, value|
204
+ relation = relation.where(attribute_to_sql(attr_name, record) => resolve_attribute_value(record, attr_name, value))
205
+ end
206
+ relation
207
+ end
208
+
209
+ # Exclude a persisted record from the comparison by its primary key
210
+ # return array in form [sql_conditions, sql_values]
211
+ def primary_key_exclusion(record)
212
+ return ['', {}] if record.new_record?
213
+ key = primary_key_value(primary_key(record), record)
214
+ ["#{record_table_name(record)}.#{primary_key(record)} != :record_primary_key_value", { record_primary_key_value: key }]
121
215
  end
122
216
 
123
217
  def primary_key(record)
@@ -134,12 +228,9 @@ class OverlapValidator < ActiveModel::EachValidator
134
228
  def generate_overlap_sql_conditions(record, sql_values)
135
229
  starts_at_attr, ends_at_attr = attributes_to_sql(record)
136
230
  main_condition = condition_string(starts_at_attr, ends_at_attr, sql_values)
137
- if record.new_record?
138
- [main_condition, {}]
139
- else
140
- key = primary_key_value(primary_key(record), record)
141
- ["#{main_condition} AND #{record_table_name(record)}.#{primary_key(record)} != :record_primary_key_value", { record_primary_key_value: key }]
142
- end
231
+ pk_conditions, pk_values = primary_key_exclusion(record)
232
+ return [main_condition, {}] if pk_conditions.empty?
233
+ ["#{main_condition} AND #{pk_conditions}", pk_values]
143
234
  end
144
235
 
145
236
  # Return hash of values for overlap sql condition; a nil endpoint means the
@@ -0,0 +1,26 @@
1
+ module ValidatesOverlap
2
+ # Included into a model class when it declares an overlap validation
3
+ module OverlappingRecords
4
+ # The records whose ranges overlap this record's range, freshly queried on
5
+ # every call and respecting the validation's options (scope, scoped_model,
6
+ # shifts, exclude_edges, and self-exclusion for persisted records).
7
+ # Returns an ActiveRecord::Relation — no records are loaded until used.
8
+ def overlapping_records
9
+ relations = self.class.validators.grep(OverlapValidator).map { |validator| validator.overlapping_records_for(self) }
10
+ # a validation whose range value is nil conflicts with nothing — its empty
11
+ # relation must not join the union: Relation#or keeps a NullRelation's
12
+ # emptiness on Rails 6.1/7.0, which would hide the other validations' conflicts
13
+ # (Relation#null_relation? arrived in 7.1; before that .none is detectable
14
+ # by its extended NullRelation module, a constant removed in Rails 8)
15
+ relations = relations.reject do |relation|
16
+ relation.respond_to?(:null_relation?) ? relation.null_relation? : relation.extending_values.include?(ActiveRecord::NullRelation)
17
+ end
18
+ return self.class.none if relations.empty?
19
+ models = relations.map(&:klass).uniq
20
+ # Relation#or never compares the relations' classes — a union across models
21
+ # would silently run one validation's conditions against the other's table
22
+ raise ArgumentError, "overlapping_records cannot combine overlap validations that query different models (#{models.join(', ')}) — query each model separately" if models.size > 1
23
+ relations.reduce(:or)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,73 @@
1
+ module ValidatesOverlap
2
+ # Opt-in companion to the exclusion constraint (see MigrationHelpers): in the
3
+ # race window the validator cannot see, the constraint raises — this concern
4
+ # rescues that and turns it into a normal validation failure: save returns
5
+ # false with errors populated, save! raises ActiveRecord::RecordInvalid.
6
+ #
7
+ # NOTE: a statement error aborts the innermost database transaction, so when
8
+ # the caller already has a joinable transaction open, save runs in its own
9
+ # savepoint (requires_new) with the rescue OUTSIDE it — otherwise the rescue
10
+ # would leave the caller's transaction in the aborted state. (The same
11
+ # pattern the Rails guides document for rescuing RecordNotUnique.)
12
+ # In every other situation the wrapper must NOT be added: ActiveRecord's own
13
+ # save transaction already contains the error there, and an extra wrapper at
14
+ # top level makes the save transaction JOIN it — silently swallowing the
15
+ # rollback a halted callback (throw :abort) relies on.
16
+ #
17
+ # == Example:
18
+ # class Meeting < ActiveRecord::Base
19
+ # validates :starts_at, :ends_at, overlap: { scope: :user_id }
20
+ # include ValidatesOverlap::RescueExclusionViolation
21
+ # end
22
+ module RescueExclusionViolation
23
+ def save(...)
24
+ contain_exclusion_violation { super }
25
+ rescue ActiveRecord::StatementInvalid => e
26
+ raise unless ValidatesOverlap.exclusion_violation?(e)
27
+ add_overlap_error
28
+ false
29
+ end
30
+
31
+ def save!(...)
32
+ contain_exclusion_violation { super }
33
+ rescue ActiveRecord::StatementInvalid => e
34
+ raise unless ValidatesOverlap.exclusion_violation?(e)
35
+ add_overlap_error
36
+ raise ActiveRecord::RecordInvalid, self
37
+ end
38
+
39
+ private
40
+
41
+ # See the NOTE above: the savepoint exists to protect a caller's open
42
+ # joinable transaction, and only PostgreSQL can raise the violation this
43
+ # concern rescues — everywhere else the wrapper would be pure overhead
44
+ # (a SAVEPOINT/RELEASE round-trip per save) or actively harmful (the
45
+ # swallowed rollback-on-abort at top level)
46
+ def contain_exclusion_violation(&block)
47
+ connection = self.class.connection
48
+ if connection.adapter_name.match?(/postgresql/i) && connection.transaction_open? && connection.current_transaction.joinable?
49
+ self.class.transaction(requires_new: true, &block)
50
+ else
51
+ yield
52
+ end
53
+ end
54
+
55
+ # mirror the validator's error placement exactly (add_overlap_error on the
56
+ # validator handles array titles and the :base fallback); with several
57
+ # overlap validations the database does not tell us which constraint
58
+ # fired — the first validator's message settings are used
59
+ def add_overlap_error
60
+ validator = self.class.validators.grep(OverlapValidator).first
61
+ if validator
62
+ validator.add_overlap_error(self)
63
+ else
64
+ errors.add(:base, :overlap)
65
+ end
66
+ end
67
+ end
68
+
69
+ def self.exclusion_violation?(error)
70
+ return true if defined?(ActiveRecord::ExclusionViolation) && error.is_a?(ActiveRecord::ExclusionViolation)
71
+ !!(defined?(PG::ExclusionViolation) && error.cause.is_a?(PG::ExclusionViolation))
72
+ end
73
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ValidatesOverlap
4
- VERSION = '1.2.0'
4
+ VERSION = '1.3.0'
5
5
  end
@@ -1,2 +1,11 @@
1
1
  require_relative 'validates_overlap/version'
2
+ require_relative 'validates_overlap/overlapping_records'
2
3
  require_relative 'validates_overlap/overlap_validator'
4
+ require_relative 'validates_overlap/migration_helpers'
5
+ require_relative 'validates_overlap/rescue_exclusion_violation'
6
+
7
+ module ValidatesOverlap
8
+ def self.deprecator
9
+ @deprecator ||= ActiveSupport::Deprecation.new('2.0', 'validates_overlap')
10
+ end
11
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: validates_overlap
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robin Bortlik
@@ -67,7 +67,7 @@ dependencies:
67
67
  - !ruby/object:Gem::Version
68
68
  version: '0'
69
69
  - !ruby/object:Gem::Dependency
70
- name: pry
70
+ name: pg
71
71
  requirement: !ruby/object:Gem::Requirement
72
72
  requirements:
73
73
  - - ">="
@@ -81,7 +81,7 @@ dependencies:
81
81
  - !ruby/object:Gem::Version
82
82
  version: '0'
83
83
  - !ruby/object:Gem::Dependency
84
- name: rails
84
+ name: pry
85
85
  requirement: !ruby/object:Gem::Requirement
86
86
  requirements:
87
87
  - - ">="
@@ -95,7 +95,7 @@ dependencies:
95
95
  - !ruby/object:Gem::Version
96
96
  version: '0'
97
97
  - !ruby/object:Gem::Dependency
98
- name: rb-readline
98
+ name: rails
99
99
  requirement: !ruby/object:Gem::Requirement
100
100
  requirements:
101
101
  - - ">="
@@ -184,15 +184,19 @@ files:
184
184
  - lib/validates_overlap/locale/es.yml
185
185
  - lib/validates_overlap/locale/pt-BR.yml
186
186
  - lib/validates_overlap/locale/ru.yml
187
+ - lib/validates_overlap/migration_helpers.rb
187
188
  - lib/validates_overlap/overlap_validator.rb
189
+ - lib/validates_overlap/overlapping_records.rb
190
+ - lib/validates_overlap/rescue_exclusion_violation.rb
188
191
  - lib/validates_overlap/version.rb
189
192
  homepage: https://github.com/tilo/validates_overlap
190
193
  licenses:
191
194
  - MIT
192
195
  metadata:
193
- source_code_uri: https://github.com/tilo/validates_overlap/tree/v1.2.0
196
+ source_code_uri: https://github.com/tilo/validates_overlap/tree/v1.3.0
194
197
  bug_tracker_uri: https://github.com/tilo/validates_overlap/issues
195
198
  changelog_uri: https://github.com/tilo/validates_overlap/blob/main/CHANGELOG.md
199
+ documentation_uri: https://www.rubydoc.info/gems/validates_overlap
196
200
  rubygems_mfa_required: 'true'
197
201
  rdoc_options: []
198
202
  require_paths: