validates_overlap 1.1.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: e43124f32b6ea56070aceb59e6149abff30b32a077150cee473e0142c75dbbcf
4
- data.tar.gz: cd44ffa9dd62741e92cd0447df0da93680527535750b747cc298caeb284fbebb
3
+ metadata.gz: e58e04fbe91942b00130f68c9e8d838838039d5ba6dc5218e563cc34d2deaf06
4
+ data.tar.gz: 995aafe99900907fc2c43536969a6c3984026a29da87998b2c869371012ea84e
5
5
  SHA512:
6
- metadata.gz: ee62420473abe259fd2259296637b30945de74fe4b3a09d11dbd61dd0042479867d47fca0916f2bbbad3f75335ea51d02ae0b6c283ee9a593e939cc883efa323
7
- data.tar.gz: 16f7b1c95f79023a49e33ed568cfeb7a2ab6af1398129f43a1469d22e07a68b77b570c84676ad2ea72c94606ef8a5abac9d55dcfe436e5a808c36dd7d7f633e7
6
+ metadata.gz: dbd9aaf4d1a31d36d27222413f83d97b5cf9658b805b5de0b4a61fae7dcbc7e57833e82419b6144a9cb0a8097160167dbbf8202bb58314ac2ac537d23455b361
7
+ data.tar.gz: b7da9ddfb4e78fb8821af1a5d3866cda098e5f7b14f2af3506aef1b7ea67390e16f3b59c5987d25b83513bf52902473e1b2b5b6673f1d074bfa72dc0b63640ff
data/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
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
+
19
+ ## 1.2.0 (2026-08-11)
20
+
21
+ RSpec tests: **85 → 126** (+41 tests)
22
+
23
+ ### Bug Fixes
24
+
25
+ - 🎉 the validator is now stateless and thread-safe 🎉 — fixes [Issue #50](https://github.com/tilo/validates_overlap/issues/50): concurrent validations of the same model class could corrupt each other's query, because Rails shares one validator instance per class and the query lived on it as instance state (intermittent `ActiveRecord::PreparedStatementInvalid`, or silently wrong validation results). Thanks to [Jorge Santos](https://github.com/jsantos) for the report
26
+ - string range columns raised `TypeError` because a default shift of `0` was added even when no shift was configured — shifts are now only applied when set
27
+ - open-ended (nil) endpoints produced wrong results in several cases: an endless range failed to conflict with records after January 2038 (the nil endpoint was substituted with a Unix-time sentinel), and open-ended integer or string ranges could silently never conflict at all. A nil endpoint now simply drops its comparison from the query — type-independent and exact
28
+ - the record's primary key is now passed to the database as a bind value when a persisted record is excluded from the comparison — it was interpolated into the SQL, which broke string keys containing a quote
29
+
30
+ ### Improvements
31
+
32
+ - documented in the README that `start_shift` / `end_shift` work in both directions: widening the range enforces a minimum gap, shrinking it tolerates a specified amount of overlap — now locked in by specs
33
+ - the overlap check works on any linearly orderable column type — now covered by specs for date, datetime, timestamp, integer, decimal, and string range columns (including open-ended ranges and integer gap/tolerance shifts) and documented in the README ("non-date ranges")
34
+ - `:time` range columns now raise `OverlapValidator::UnsupportedColumnType` — time-of-day is a cyclic domain, where a wraparound window is indistinguishable from accidentally swapped fields; the validator refuses loudly instead of answering wrong (see the README note for cyclic domains)
35
+ - test coverage: real UUID/string primary key test restored (lost in a 2019 refactor), new tests for `:scoped_model`, literal scope values, and the two-attributes requirement; the long-disabled endless-objects test was fixed and re-enabled — the suite has no pending tests
36
+
37
+ ### Internal
38
+
39
+ - removed the accessors `sql_conditions`, `sql_values`, and `scoped_model` from `OverlapValidator` — they were the shared state causing thread-safety issues; the query-building methods now take and return their inputs
40
+ - removed the constants `OverlapValidator::BEGIN_OF_UNIX_TIME` and `END_OF_UNIX_TIME` — the sentinel substitution is gone; an open-ended boundary simply contributes no comparison to the query
41
+
3
42
  ## 1.1.0 (2026-08-07)
4
43
 
5
44
  RSpec tests: **81 → 85** (+4 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
@@ -1,32 +1,14 @@
1
1
  # ValidatesOverlap
2
2
 
3
- ![Gem Version](https://img.shields.io/gem/v/validates_overlap) [![Ruby](https://github.com/tilo/validates_overlap/actions/workflows/ruby.yml/badge.svg)](https://github.com/tilo/validates_overlap/actions/workflows/ruby.yml) [![codecov](https://codecov.io/gh/tilo/validates_overlap/branch/main/graph/badge.svg)](https://codecov.io/gh/tilo/validates_overlap) [![Downloads](https://img.shields.io/gem/dt/validates_overlap)](https://rubygems.org/gems/validates_overlap) [![RubyGems](https://img.shields.io/badge/RubyGems-validates__overlap-brightgreen?logo=rubygems&logoColor=white)](https://rubygems.org/gems/validates_overlap) [![Ruby Toolbox](https://img.shields.io/badge/Ruby%20Toolbox-validates__overlap-brightgreen)](https://www.ruby-toolbox.com/projects/validates_overlap)
3
+ ![Gem Version](https://img.shields.io/gem/v/validates_overlap) [![RSpec](https://github.com/tilo/validates_overlap/actions/workflows/ruby.yml/badge.svg)](https://github.com/tilo/validates_overlap/actions/workflows/ruby.yml) [![codecov](https://codecov.io/gh/tilo/validates_overlap/branch/main/graph/badge.svg)](https://app.codecov.io/gh/tilo/validates_overlap/tree/main) [![Downloads](https://img.shields.io/gem/dt/validates_overlap)](https://rubygems.org/gems/validates_overlap) [![RubyGems](https://img.shields.io/badge/RubyGems-validates__overlap-brightgreen?logo=rubygems&logoColor=white)](https://rubygems.org/gems/validates_overlap) [![Ruby Toolbox](https://img.shields.io/badge/Ruby%20Toolbox-validates__overlap-brightgreen)](https://www.ruby-toolbox.com/projects/validates_overlap)
4
4
 
5
- `validates_overlap` adds an overlap validation to ActiveRecord models.
6
- Ideal solution for booking applications where you want to make sure, that one place can be booked only once in specific time period.
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).
7
6
 
8
- You name the two attributes that define a time range — for example starts_at and ends_at — and the validator checks with a single SQL query that no other record's range overlaps it. If one does, the record gets 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.
9
8
 
10
- Typical uses: bookings, reservations, meetings, work shifts, rentals anywhere a resource must not be double-booked for the same period.
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.
11
10
 
12
- The check runs entirely in the database, so no records are loaded to compare against. It supports scoping the comparison (per user, per room, …), open-ended ranges (a nil start or end counts as extending forever), ranges that may touch at the edges, required gaps between ranges, validating through associations, and loading the conflicting records when you want to show them to the user.
13
-
14
- ## Compatibility
15
-
16
- Every combination below is verified on every push by the [CI matrix](https://github.com/tilo/validates_overlap/actions):
17
-
18
- | Rails | Tested with Ruby |
19
- |-------|--------------------|
20
- | 8.1 | 3.2, 3.3, 3.4 |
21
- | 8.0 | 3.2, 3.3, 3.4 |
22
- | 7.2 | 3.1, 3.2, 3.3, 3.4 |
23
- | 7.1 | 3.0, 3.1, 3.2, 3.3 |
24
- | 7.0 | 3.0, 3.1, 3.2 |
25
- | 6.1 | 3.0 |
26
-
27
- 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.
28
-
29
- ## Usage
11
+ ## Quick Start
30
12
 
31
13
  Add to your gemfile
32
14
 
@@ -36,77 +18,67 @@ gem 'validates_overlap'
36
18
 
37
19
  In your model
38
20
 
39
- #### without scope
40
-
41
21
  ```ruby
42
22
  validates :starts_at, :ends_at, :overlap => true
43
- ```
44
23
 
45
- #### with scope
46
-
47
- ```ruby
24
+ # or scoped, e.g. per user:
48
25
  validates :starts_at, :ends_at, :overlap => {:scope => "user_id"}
49
26
  ```
50
27
 
51
- #### exclude edge(s)
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).
52
29
 
53
- ```ruby
54
- validates :starts_at, :ends_at, :overlap => {:exclude_edges => "starts_at"}
55
- validates :starts_at, :ends_at, :overlap => {:exclude_edges => ["starts_at", "ends_at"]}
56
- ```
30
+ ## Documentation
57
31
 
58
- #### shift edges
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)
59
36
 
60
- ```ruby
61
- validates :starts_at, :ends_at, :overlap => {:start_shift => -1.day, :end_shift => 1.day}
62
- ```
37
+ ## Range Types
38
+
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.
40
+
41
+ ## ⚠️ Concurrent Writes
63
42
 
64
- #### define custom validation key(s) and message
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:
65
44
 
66
45
  ```ruby
67
- validates :starts_at, :ends_at, :overlap => {:message_title => "Some validation title", :message_content => "Some validation message"}
68
- validates :starts_at, :ends_at, :overlap => {:message_title => [:start_at, :end_at], :message_content => "Some validation message"}
46
+ add_overlap_constraint :meetings, :starts_at, :ends_at, scope: :user_id
69
47
  ```
70
48
 
71
- #### with complicated relations
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.
72
51
 
73
- Example describes valildatation of user, positions and time slots.
74
- User can't be assigned 2 times on position which is under time slot with time overlap.
52
+ ## Note: Add an index the overlap check runs on every save
53
+
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:
75
55
 
76
56
  ```ruby
77
- class Position < ActiveRecord::Base
78
- belongs_to :time_slot
79
- belongs_to :user
80
- validates "time_slots.starts_at", "time_slots.ends_at",
81
- :overlap => {
82
- :query_options => {:joins => :time_slot},
83
- :scope => { "positions.user_id" => proc{|position| position.user_id} }
84
- }
85
- end
57
+ add_index :meetings, [:user_id, :starts_at, :ends_at]
86
58
  ```
87
59
 
88
- #### 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.
89
61
 
90
- ```ruby
91
- class ActiveMeeting < ActiveRecord::Base
92
- validates :starts_at, :ends_at, :overlap => {:query_options => {:active => nil}}
93
- scope :active, where(:is_active => true)
94
- end
95
- ```
62
+ ## Ruby / Rails Compatibility
96
63
 
97
- #### Overlapped records
98
- 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):
99
65
 
100
- ```ruby
101
- class ActiveMeeting < ActiveRecord::Base
102
- 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 |
103
74
 
104
- def overlapped_records
105
- @overlapped_records || []
106
- end
107
- 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.
108
76
 
109
- ```
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).
110
82
 
111
83
  ## Maintainership
112
84
 
@@ -114,5 +86,3 @@ end
114
86
  Since August 2026 the gem is maintained by [Tilo Sloboda](https://github.com/tilo).
115
87
 
116
88
  A big thank you to Robin for creating this awesome gem and for the years of work he put into it. ❤️
117
-
118
-
@@ -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
@@ -3,58 +3,137 @@ require 'active_support/i18n'
3
3
  I18n.load_path << File.dirname(__FILE__) + '/locale/en.yml'
4
4
 
5
5
  class OverlapValidator < ActiveModel::EachValidator
6
- BEGIN_OF_UNIX_TIME = Time.at(-2_147_483_648).to_datetime
7
- END_OF_UNIX_TIME = Time.at(2_147_483_648).to_datetime
6
+ # Raised when a range attribute uses a column type the validator cannot
7
+ # operate on (e.g. :time — a cyclic domain, see README)
8
+ class UnsupportedColumnType < ArgumentError; end
8
9
 
9
- attr_accessor :sql_conditions
10
- attr_accessor :sql_values
11
- attr_accessor :scoped_model
10
+ # PostgreSQL range column types usable with the single-attribute form
11
+ RANGE_COLUMN_TYPES = %i[tsrange tstzrange daterange int4range int8range numrange].freeze
12
12
 
13
13
  def initialize(args)
14
14
  attributes_are_range(args[:attributes])
15
+ reject_range_column_options(args)
16
+ model_class = args[:class]
15
17
 
16
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)
17
33
  end
18
34
 
35
+ # NOTE: Rails registers ONE validator instance per model class, shared by every
36
+ # validation of that class (including concurrent ones) — so the query being
37
+ # built must never be stored on the validator itself (issue #50)
19
38
  def validate(record)
20
- initialize_query(record, options)
21
- if overlapped_exists?
39
+ reject_unsupported_column_types(record)
40
+ relation, sql_conditions, sql_values = initialize_query(record, options)
41
+ if overlapped_exists?(relation, sql_conditions, sql_values)
22
42
  if options[:load_overlapped]
23
- record.instance_variable_set(:@overlapped_records, get_overlapped)
43
+ record.instance_variable_set(:@overlapped_records, get_overlapped(relation, sql_conditions, sql_values))
24
44
  end
25
45
 
26
- if record.respond_to? attributes.first
27
- if options[:message_title].is_a?(Array)
28
- options[:message_title].each do |key|
29
- record.errors.add(key, options[:message_content] || :overlap)
30
- end
31
- else
32
- 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)
33
58
  end
34
59
  else
35
- record.errors.add(options[:message_title] || :base, options[:message_content] || :overlap)
60
+ record.errors.add(options[:message_title] || attributes.first, options[:message_content] || :overlap)
36
61
  end
62
+ else
63
+ record.errors.add(options[:message_title] || :base, options[:message_content] || :overlap)
37
64
  end
38
65
  end
39
66
 
40
67
  protected
41
68
 
69
+ # Time-of-day is a cyclic domain: every pair of values denotes some valid
70
+ # range there, so wraparound intent is indistinguishable from accidentally
71
+ # swapped fields — refuse loudly instead of answering wrong (see README).
72
+ # Checked at validate time, not at class-definition time, because column
73
+ # metadata must not be touched while migrations may still be pending.
74
+ def reject_unsupported_column_types(record)
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
83
+ attributes.each do |attr|
84
+ next if attr.to_s.include?('.')
85
+ column = record.class.columns_hash[attr.to_s]
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
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)"
92
+ end
93
+ end
94
+
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.
112
+ # return array in form [relation, sql_conditions, sql_values]
42
113
  def initialize_query(record, options = {})
43
114
  scoped_model = options[:scoped_model].present? ? options[:scoped_model].constantize : record.class
44
- self.scoped_model = scoped_model.default_scoped
45
- generate_overlap_sql_values(record)
46
- generate_overlap_sql_conditions(record)
47
- add_attributes(record, options[:scope]) if options && options[:scope].present?
48
- add_query_options(options[:query_options]) if options && options[:query_options].present?
115
+ relation = scoped_model.default_scoped
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
126
+ relation = add_query_options(relation, options[:query_options]) if options && options[:query_options].present?
127
+ [relation, sql_conditions, sql_values]
49
128
  end
50
129
 
51
130
  # Check if exists at least one record in DB which is overlapped with current record
52
- def overlapped_exists?
53
- scoped_model.exists?([sql_conditions, sql_values])
131
+ def overlapped_exists?(relation, sql_conditions, sql_values)
132
+ sql_conditions ? relation.exists?([sql_conditions, sql_values]) : relation.exists?
54
133
  end
55
134
 
56
- def get_overlapped
57
- scoped_model.where([sql_conditions, sql_values])
135
+ def get_overlapped(relation, sql_conditions, sql_values)
136
+ sql_conditions ? relation.where([sql_conditions, sql_values]) : relation
58
137
  end
59
138
 
60
139
  # Resolve attributes values from record to use in sql conditions
@@ -95,9 +174,44 @@ class OverlapValidator < ActiveModel::EachValidator
95
174
  record.class.table_name
96
175
  end
97
176
 
98
- # 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
99
178
  def attributes_are_range(attributes)
100
- 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 }]
101
215
  end
102
216
 
103
217
  def primary_key(record)
@@ -108,55 +222,67 @@ class OverlapValidator < ActiveModel::EachValidator
108
222
  record.send(primary_key_name)
109
223
  end
110
224
 
111
- # Generate sql condition for time range cross
112
- def generate_overlap_sql_conditions(record)
225
+ # Generate sql condition for time range cross; a persisted record is excluded
226
+ # from the comparison by its primary key, passed as a bind value
227
+ # return array in form [sql_conditions, sql_values]
228
+ def generate_overlap_sql_conditions(record, sql_values)
113
229
  starts_at_attr, ends_at_attr = attributes_to_sql(record)
114
- main_condition = condition_string(starts_at_attr, ends_at_attr)
115
- primary_key_name = primary_key(record)
116
- key = primary_key_value(primary_key_name, record)
117
- if record.new_record?
118
- self.sql_conditions = main_condition
119
- else
120
- self.sql_conditions = "#{main_condition} AND #{record_table_name(record)}.#{primary_key(record)} !="
121
- self.sql_conditions += key.is_a?(String) ? "'#{key}'" : key.to_s
122
- end
230
+ main_condition = condition_string(starts_at_attr, ends_at_attr, sql_values)
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]
123
234
  end
124
235
 
125
- # Return hash of values for overlap sql condition
236
+ # Return hash of values for overlap sql condition; a nil endpoint means the
237
+ # record's range is open-ended on that side — no value is emitted for it and
238
+ # condition_string drops the corresponding comparison
239
+ # NOTE: shifts are only applied when configured — unconditionally adding a
240
+ # default of 0 would raise a TypeError for non-numeric types such as String
126
241
  def generate_overlap_sql_values(record)
127
242
  starts_at_value, ends_at_value = resolve_values_from_attributes(record)
128
- starts_at_value += options.fetch(:start_shift) { 0 } if starts_at_value && options
129
- ends_at_value += options.fetch(:end_shift) { 0 } if ends_at_value && options
130
- self.sql_values = { starts_at_value: starts_at_value || BEGIN_OF_UNIX_TIME, ends_at_value: ends_at_value || END_OF_UNIX_TIME }
243
+ start_shift = options && options[:start_shift]
244
+ end_shift = options && options[:end_shift]
245
+ starts_at_value += start_shift if starts_at_value && start_shift
246
+ ends_at_value += end_shift if ends_at_value && end_shift
247
+ sql_values = {}
248
+ sql_values[:starts_at_value] = starts_at_value if starts_at_value
249
+ sql_values[:ends_at_value] = ends_at_value if ends_at_value
250
+ sql_values
131
251
  end
132
252
 
133
253
  # Return the condition string depend on exclude_edges option.
134
- def condition_string(starts_at_attr, ends_at_attr)
254
+ # A comparison is only emitted for endpoints the record actually has: an
255
+ # open-ended side matches every other record by definition, so its clause is
256
+ # dropped (a record with both endpoints nil overlaps everything)
257
+ def condition_string(starts_at_attr, ends_at_attr, sql_values)
135
258
  except_option = Array(options[:exclude_edges]).map(&:to_s)
136
259
  starts_at_sign = except_option.include?(starts_at_attr.to_s.split('.').last) ? '<' : '<='
137
260
  ends_at_sign = except_option.include?(ends_at_attr.to_s.split('.').last) ? '>' : '>='
138
261
  query = []
139
- query << "(#{ends_at_attr} IS NULL OR #{ends_at_attr} #{ends_at_sign} :starts_at_value)"
140
- query << "(#{starts_at_attr} IS NULL OR #{starts_at_attr} #{starts_at_sign} :ends_at_value)"
141
- query.join(' AND ')
262
+ query << "(#{ends_at_attr} IS NULL OR #{ends_at_attr} #{ends_at_sign} :starts_at_value)" if sql_values.key?(:starts_at_value)
263
+ query << "(#{starts_at_attr} IS NULL OR #{starts_at_attr} #{starts_at_sign} :ends_at_value)" if sql_values.key?(:ends_at_value)
264
+ query.empty? ? '1 = 1' : query.join(' AND ')
142
265
  end
143
266
 
144
267
  # Add attributes and values to sql conditions.
145
268
  # helps to use with scope options, so scope can be added as this forms :scope => "user_id" or :scope => ["user_id", "place_id"]
146
- def add_attributes(record, attrs)
269
+ # return array in form [sql_conditions, sql_values]
270
+ def add_attributes(record, attrs, sql_conditions, sql_values)
147
271
  if attrs.is_a?(Array)
148
- attrs.each { |attr| add_attribute(record, attr) }
272
+ attrs.each { |attr| sql_conditions, sql_values = add_attribute(record, attr, sql_conditions, sql_values) }
149
273
  elsif attrs.is_a?(Hash)
150
274
  attrs.each do |attr_name, value|
151
- add_attribute(record, attr_name, value)
275
+ sql_conditions, sql_values = add_attribute(record, attr_name, sql_conditions, sql_values, value)
152
276
  end
153
277
  else
154
- add_attribute(record, attrs)
278
+ sql_conditions, sql_values = add_attribute(record, attrs, sql_conditions, sql_values)
155
279
  end
280
+ [sql_conditions, sql_values]
156
281
  end
157
282
 
158
283
  # Add attribute and his value to sql condition
159
- def add_attribute(record, attr_name, value = nil)
284
+ # return array in form [sql_conditions, sql_values]
285
+ def add_attribute(record, attr_name, sql_conditions, sql_values, value = nil)
160
286
  _value = resolve_attribute_value(record, attr_name, value)
161
287
  operator = if _value.nil?
162
288
  ' IS NULL'
@@ -166,8 +292,9 @@ class OverlapValidator < ActiveModel::EachValidator
166
292
  ' = :%s'
167
293
  end
168
294
 
169
- self.sql_conditions += " AND #{attribute_to_sql(attr_name, record)} #{operator}" % value_attribute_name(attr_name)
170
- sql_values.merge!(:"#{value_attribute_name(attr_name)}" => _value)
295
+ sql_conditions += " AND #{attribute_to_sql(attr_name, record)} #{operator}" % value_attribute_name(attr_name)
296
+ sql_values = sql_values.merge(:"#{value_attribute_name(attr_name)}" => _value)
297
+ [sql_conditions, sql_values]
171
298
  end
172
299
 
173
300
  def value_attribute_name(attr_name)
@@ -200,9 +327,11 @@ class OverlapValidator < ActiveModel::EachValidator
200
327
  # Allow to use scope, joins, includes methods before querying
201
328
  # == Example:
202
329
  # validates_overlap :date_from, :date_to, :query_options => {:includes => "visits"}
203
- def add_query_options(methods)
330
+ # return the relation with the query options applied
331
+ def add_query_options(relation, methods)
204
332
  methods.each do |method_name, params|
205
- self.scoped_model = scoped_model.send(method_name.to_sym, *params)
333
+ relation = relation.send(method_name.to_sym, *params)
206
334
  end
335
+ relation
207
336
  end
208
337
  end
@@ -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.1.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.1.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
  - - ">="
@@ -164,8 +164,9 @@ dependencies:
164
164
  - - ">="
165
165
  - !ruby/object:Gem::Version
166
166
  version: '0'
167
- description: It can be useful when you are developing some app where you will work
168
- with meetings, events etc.
167
+ description: Adds ActiveRecord validations that prevent overlapping date/time ranges
168
+ bookings, reservations, meetings, shifts. One SQL query; supports scoping and
169
+ open-ended ranges
169
170
  email:
170
171
  - robinbortlik@gmail.com
171
172
  - tilo.sloboda@gmail.com
@@ -183,15 +184,19 @@ files:
183
184
  - lib/validates_overlap/locale/es.yml
184
185
  - lib/validates_overlap/locale/pt-BR.yml
185
186
  - lib/validates_overlap/locale/ru.yml
187
+ - lib/validates_overlap/migration_helpers.rb
186
188
  - lib/validates_overlap/overlap_validator.rb
189
+ - lib/validates_overlap/overlapping_records.rb
190
+ - lib/validates_overlap/rescue_exclusion_violation.rb
187
191
  - lib/validates_overlap/version.rb
188
192
  homepage: https://github.com/tilo/validates_overlap
189
193
  licenses:
190
194
  - MIT
191
195
  metadata:
192
- source_code_uri: https://github.com/tilo/validates_overlap/tree/v1.1.0
196
+ source_code_uri: https://github.com/tilo/validates_overlap/tree/v1.3.0
193
197
  bug_tracker_uri: https://github.com/tilo/validates_overlap/issues
194
198
  changelog_uri: https://github.com/tilo/validates_overlap/blob/main/CHANGELOG.md
199
+ documentation_uri: https://www.rubydoc.info/gems/validates_overlap
195
200
  rubygems_mfa_required: 'true'
196
201
  rdoc_options: []
197
202
  require_paths: