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 +4 -4
- data/CHANGELOG.md +16 -0
- data/CONTRIBUTORS.md +2 -1
- data/README.md +36 -113
- data/lib/validates_overlap/migration_helpers.rb +146 -0
- data/lib/validates_overlap/overlap_validator.rb +115 -24
- data/lib/validates_overlap/overlapping_records.rb +26 -0
- data/lib/validates_overlap/rescue_exclusion_violation.rb +73 -0
- data/lib/validates_overlap/version.rb +1 -1
- data/lib/validates_overlap.rb +9 -0
- metadata +9 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e58e04fbe91942b00130f68c9e8d838838039d5ba6dc5218e563cc34d2deaf06
|
|
4
|
+
data.tar.gz: 995aafe99900907fc2c43536969a6c3984026a29da87998b2c869371012ea84e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
24
|
+
# or scoped, e.g. per user:
|
|
74
25
|
validates :starts_at, :ends_at, :overlap => {:scope => "user_id"}
|
|
75
26
|
```
|
|
76
27
|
|
|
77
|
-
|
|
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
|
-
|
|
30
|
+
## Documentation
|
|
85
31
|
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
+
## ⚠️ Concurrent Writes
|
|
97
42
|
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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] ||
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
data/lib/validates_overlap.rb
CHANGED
|
@@ -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.
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
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:
|