validates_overlap 1.0.0 → 1.2.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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/CONTRIBUTORS.md +26 -0
  4. data/MIT-LICENSE +2 -1
  5. data/README.md +81 -8
  6. data/lib/validates_overlap/overlap_validator.rb +82 -44
  7. data/lib/validates_overlap/version.rb +5 -0
  8. data/lib/validates_overlap.rb +2 -1
  9. metadata +55 -135
  10. data/.gitignore +0 -11
  11. data/.rspec +0 -2
  12. data/.rubocop.yml +0 -5
  13. data/.travis.yml +0 -6
  14. data/Gemfile +0 -3
  15. data/Gemfile.rails60 +0 -5
  16. data/Rakefile +0 -39
  17. data/VERSION +0 -1
  18. data/spec/dummy/Rakefile +0 -7
  19. data/spec/dummy/app/controllers/application_controller.rb +0 -3
  20. data/spec/dummy/app/helpers/application_helper.rb +0 -2
  21. data/spec/dummy/app/models/active_meeting.rb +0 -4
  22. data/spec/dummy/app/models/document_with_enum.rb +0 -26
  23. data/spec/dummy/app/models/end_overlap_meeting.rb +0 -3
  24. data/spec/dummy/app/models/meeting.rb +0 -3
  25. data/spec/dummy/app/models/position.rb +0 -9
  26. data/spec/dummy/app/models/secure_meeting.rb +0 -3
  27. data/spec/dummy/app/models/shift.rb +0 -3
  28. data/spec/dummy/app/models/start_end_overlap_meeting.rb +0 -3
  29. data/spec/dummy/app/models/start_overlap_meeting.rb +0 -3
  30. data/spec/dummy/app/models/time_slot.rb +0 -8
  31. data/spec/dummy/app/models/user.rb +0 -2
  32. data/spec/dummy/app/models/user_meeting.rb +0 -3
  33. data/spec/dummy/app/views/layouts/application.html.erb +0 -14
  34. data/spec/dummy/config/application.rb +0 -45
  35. data/spec/dummy/config/boot.rb +0 -10
  36. data/spec/dummy/config/database.yml +0 -22
  37. data/spec/dummy/config/environment.rb +0 -5
  38. data/spec/dummy/config/environments/development.rb +0 -25
  39. data/spec/dummy/config/environments/production.rb +0 -49
  40. data/spec/dummy/config/environments/test.rb +0 -35
  41. data/spec/dummy/config/initializers/backtrace_silencers.rb +0 -7
  42. data/spec/dummy/config/initializers/inflections.rb +0 -10
  43. data/spec/dummy/config/initializers/mime_types.rb +0 -5
  44. data/spec/dummy/config/initializers/secret_token.rb +0 -7
  45. data/spec/dummy/config/initializers/session_store.rb +0 -8
  46. data/spec/dummy/config/locales/en.yml +0 -5
  47. data/spec/dummy/config/routes.rb +0 -58
  48. data/spec/dummy/config.ru +0 -4
  49. data/spec/dummy/db/migrate/20110406082020_create_meetings.rb +0 -13
  50. data/spec/dummy/db/migrate/20110406082053_create_users.rb +0 -13
  51. data/spec/dummy/db/migrate/20110407060725_create_user_meetings.rb +0 -15
  52. data/spec/dummy/db/migrate/20120509130956_create_end_overlap_meetings.rb +0 -13
  53. data/spec/dummy/db/migrate/20120509131012_create_start_overlap_meetings.rb +0 -13
  54. data/spec/dummy/db/migrate/20120509131032_create_start_end_overlap_meetings.rb +0 -13
  55. data/spec/dummy/db/migrate/20121207162212_create_time_slot.rb +0 -20
  56. data/spec/dummy/db/migrate/20130826155107_create_active_meetings.rb +0 -14
  57. data/spec/dummy/db/migrate/20150707155107_create_secure_meetings.rb +0 -14
  58. data/spec/dummy/db/migrate/20151203153132_create_shifts.rb +0 -13
  59. data/spec/dummy/db/migrate/20170102162952_add_documents_with_enum.rb +0 -14
  60. data/spec/dummy/db/schema.rb +0 -101
  61. data/spec/dummy/public/404.html +0 -26
  62. data/spec/dummy/public/422.html +0 -26
  63. data/spec/dummy/public/500.html +0 -26
  64. data/spec/dummy/public/favicon.ico +0 -0
  65. data/spec/dummy/public/javascripts/application.js +0 -2
  66. data/spec/dummy/public/javascripts/controls.js +0 -965
  67. data/spec/dummy/public/javascripts/dragdrop.js +0 -974
  68. data/spec/dummy/public/javascripts/effects.js +0 -1123
  69. data/spec/dummy/public/javascripts/prototype.js +0 -6001
  70. data/spec/dummy/public/javascripts/rails.js +0 -191
  71. data/spec/dummy/public/stylesheets/.gitkeep +0 -0
  72. data/spec/dummy/script/rails +0 -6
  73. data/spec/dummy/spec/factories/active_meeting.rb +0 -7
  74. data/spec/dummy/spec/factories/document_with_enum.rb +0 -7
  75. data/spec/dummy/spec/factories/end_overlap_meeting.rb +0 -6
  76. data/spec/dummy/spec/factories/meeting.rb +0 -6
  77. data/spec/dummy/spec/factories/position.rb +0 -6
  78. data/spec/dummy/spec/factories/secure_meeting.rb +0 -6
  79. data/spec/dummy/spec/factories/shift.rb +0 -6
  80. data/spec/dummy/spec/factories/start_end_overlap_meeting.rb +0 -6
  81. data/spec/dummy/spec/factories/start_overlap_meeting.rb +0 -6
  82. data/spec/dummy/spec/factories/time_slot.rb +0 -6
  83. data/spec/dummy/spec/factories/user.rb +0 -5
  84. data/spec/dummy/spec/factories/user_meeting.rb +0 -13
  85. data/spec/dummy/spec/models/active_meetings_spec.rb +0 -11
  86. data/spec/dummy/spec/models/document_with_enum_spec.rb +0 -53
  87. data/spec/dummy/spec/models/end_overlap_meeting_spec.rb +0 -48
  88. data/spec/dummy/spec/models/meeting_spec.rb +0 -68
  89. data/spec/dummy/spec/models/position_spec.rb +0 -31
  90. data/spec/dummy/spec/models/secure_meeting_spec.rb +0 -14
  91. data/spec/dummy/spec/models/shift_spec.rb +0 -41
  92. data/spec/dummy/spec/models/start_end_overlap_meeting_spec.rb +0 -48
  93. data/spec/dummy/spec/models/start_overlap_meeting_spec.rb +0 -48
  94. data/spec/dummy/spec/models/time_slot_spec.rb +0 -38
  95. data/spec/dummy/spec/models/user_meeting_spec.rb +0 -34
  96. data/spec/dummy/spec/models/user_spec.rb +0 -9
  97. data/spec/dummy/spec/overlap_validator_spec.rb +0 -32
  98. data/spec/spec_helper.rb +0 -58
  99. data/validates_overlap.gemspec +0 -30
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a4cb1a64ca3a5f173591cc6e6ee54268711a12dc49da4e8e5041aedd94f40e81
4
- data.tar.gz: ad351e1f69f9c6f05dc9a38b6d23530672bc986fb19d48e890ba4a94e14ee9ba
3
+ metadata.gz: 22d9351114cf974a1843daead1ff7fb57503951d2cfecd9bfe19c2041bd4dd01
4
+ data.tar.gz: 95e4e59f48f05f22ad589a06e77224fd1d065414ddb22a0d68f17e23005c0168
5
5
  SHA512:
6
- metadata.gz: 0da45c30fd0a89545921403ed0956891c3828a42494130369bc9ffba80a36070beeead19caac80c9ef0ce21773cd355f73dbab9fc8750f2ab19df0d28acb5d0a
7
- data.tar.gz: 4c3d245beba5c257f8682d2ff23dcc491caede32a68237a12838965da94101e520bd975ab761ef86d66cf44e9ac8065381612f756b2d3a93d76db3d2f87d279d
6
+ metadata.gz: 8483ac32c24e082f364de7e090dd01b6d14183657bbf6e002770b1d256904eed24e56fcf19707bfa586d79ca9be24b1b7e5e5f9d09de9f2f18d404cc0dec840b
7
+ data.tar.gz: 4b2d9a55f399584ba014ca1633bfa4e278ad42a89db9ff5db4828da9c71d1bba731ceb9394ee5cb9903f2ffa51c76f542efea40037a4e99e7ed6bcea2498f73d
data/CHANGELOG.md ADDED
@@ -0,0 +1,53 @@
1
+ # ValidatesOverlap 1.x Change Log
2
+
3
+ ## 1.2.0 (2026-08-11)
4
+
5
+ RSpec tests: **85 → 126** (+41 tests)
6
+
7
+ ### Bug Fixes
8
+
9
+ - 🎉 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
10
+ - 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
11
+ - 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
12
+ - 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
13
+
14
+ ### Improvements
15
+
16
+ - 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
17
+ - 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")
18
+ - `: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)
19
+ - 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
20
+
21
+ ### Internal
22
+
23
+ - 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
24
+ - 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
25
+
26
+ ## 1.1.0 (2026-08-07)
27
+
28
+ RSpec tests: **81 → 85** (+4 tests)
29
+
30
+ First release under new maintainership — [Tilo Sloboda](https://github.com/tilo) took over maintenance from [Robin Bortlik](https://github.com/robinbortlik) in August 2026. Thank you, Robin, for creating this gem and maintaining it for many years! ❤️
31
+
32
+ ### Bug Fixes
33
+
34
+ - fixed [Issue #54](https://github.com/tilo/validates_overlap/issues/54): a scope naming an enum attribute as a symbol (e.g. `scope: [:kind]`) crashed with `NoMethodError`. Thanks to [Nujian Den Mark Meralpis](https://github.com/denmarkmeralpis) ([PR #55](https://github.com/tilo/validates_overlap/pull/55)); covered by a new regression test
35
+
36
+ ### Improvements
37
+
38
+ - verified support for Rails 6.1, 7.0, 7.1, 7.2, 8.0, and 8.1 on Ruby 3.0–3.4 — an 18-cell GitHub Actions matrix now runs on every push (the first working CI since 2019)
39
+ - the gem now depends on `activerecord` instead of the full `rails` meta-gem, so it no longer pulls actionpack etc. into your bundle (version floor unchanged: `>= 6.0`)
40
+ - the gem package now ships only `lib/`, README, and license — previously the entire test app was packaged into the gem
41
+ - test suite modernized: migrated from the long-dead `factory_girl` to `factory_bot`, runs on current Ruby/Rails, SimpleCov added with 100% line coverage, new tests for nil scope values (`IS NULL` matching) and symbol enum scopes
42
+ - the gem version now lives in `ValidatesOverlap::VERSION` (`lib/validates_overlap/version.rb`); the `VERSION` file is gone
43
+ - README: badges, Ruby/Rails compatibility matrix, clearer description of what the gem does
44
+
45
+ ## 1.0.0 (2019-11-17)
46
+
47
+ - Rails 6 support; support for Rails 3, 4, and 5 dropped — use version 0.8.6 for those. Thanks to [Robin Bortlik](https://github.com/robinbortlik)
48
+ - avoid implicit `scoped_model.all` delegation in the validation ([PR #52](https://github.com/tilo/validates_overlap/pull/52)). Thanks to [Ryuta Kamizono](https://github.com/kamipo)
49
+ - Russian validation error message ([PR #47](https://github.com/tilo/validates_overlap/pull/47)). Thanks to [Alexander Gornov](https://github.com/Zmokizmoghi)
50
+
51
+ ## 0.8.6 (2017-11-20) and earlier
52
+
53
+ - releases from 2011 through 2017 by [Robin Bortlik](https://github.com/robinbortlik) and contributors were not tracked in a changelog; see the git history and [CONTRIBUTORS.md](CONTRIBUTORS.md)
data/CONTRIBUTORS.md ADDED
@@ -0,0 +1,26 @@
1
+ # A Big Thank You to all 21 Contributors!!
2
+
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
+
5
+ A Big Thank you to everyone who filed issues, sent comments, and who contributed with pull requests:
6
+
7
+ * [Kyle Bolton](https://github.com/kb)
8
+ * [Jon Pascoe](https://github.com/pacso)
9
+ * [Mark Campbell](https://github.com/Nitrodist)
10
+ * [Thibaut Courouble](https://github.com/Thibaut)
11
+ * [Tawan Sierek](https://github.com/tawan)
12
+ * [Martin Lagrange](https://github.com/supertinou)
13
+ * [Geoff Harcourt](https://github.com/geoffharcourt)
14
+ * [Brenton 'B-Train' Fletcher](https://github.com/bloopletech)
15
+ * [Victor Hugo Bueno](https://github.com/victorbueno)
16
+ * [rmathew1992](https://github.com/rmathew1992)
17
+ * [Mikhail Dieterle](https://github.com/mikdiet)
18
+ * [Roberto Romero](https://github.com/sildur)
19
+ * [Patrick Smith](https://github.com/royalicing)
20
+ * [Rafał Trojanowski](https://github.com/rafaltrojanowski)
21
+ * [Szymon Rut](https://github.com/RutSzymon)
22
+ * [Alexander Gornov](https://github.com/Zmokizmoghi)
23
+ * [Ryuta Kamizono](https://github.com/kamipo)
24
+ * [Nujian Den Mark Meralpis](https://github.com/denmarkmeralpis)
25
+ * [ohenrik](https://github.com/ohenrik)
26
+ * [Jorge Santos](https://github.com/jsantos)
data/MIT-LICENSE CHANGED
@@ -1,4 +1,5 @@
1
- Copyright 2011 YOURNAME
1
+ Copyright 2011 Robin Bortlik
2
+ Copyright 2026 Tilo Sloboda
2
3
 
3
4
  Permission is hereby granted, free of charge, to any person obtaining
4
5
  a copy of this software and associated documentation files (the
data/README.md CHANGED
@@ -1,15 +1,58 @@
1
- [![Build Status](https://secure.travis-ci.org/robinbortlik/validates_overlap.png?branch=master)](https://secure.travis-ci.org/robinbortlik/validates_overlap)
2
-
3
1
  # ValidatesOverlap
4
2
 
5
- This project rocks and uses MIT-LICENSE.
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
+
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
+
7
+ You specify two attributes defining a datetime range, such as `starts_at` and `ends_at`, and the validator checks with a single SQL query whether another record overlaps that range — no records are loaded for the comparison. If one does, the record receives a normal validation error.
8
+
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
+
11
+ The range columns don't have to be dates or times: any linearly orderable column type works, such as integer ranges (ticket number blocks), decimal ranges (price bands), or string ranges (alphabetical partitions).
12
+
13
+ ## Note: Other Domains
14
+
15
+ Other domains / types can be checked for overlap, as long as they can be compared linearly.
16
+ e.g. The overlap check runs on plain SQL comparisons, so any linearly orderable column type works — for example integer ranges (no two records may claim overlapping number blocks), decimal ranges (price bands), or string ranges (alphabetical partitions). A nil endpoint means the range is open-ended on that side, for these types too, and the shifts work for numeric ranges as well (e.g. an integer gap or overlap tolerance). The test suite covers `date`, `datetime`, `timestamp`, `integer`, `decimal`, and `string` range columns.
17
+
18
+ ## ⚠️ Note: Cyclic Domains can NOT be validated for overlap
19
+
20
+ Overlap validation requires a linear domain: every range must satisfy `start <= end`. On a cyclic (wrap-around) domain, like time, every pair of values denotes *some* valid range (`11:00..10:00` is simply the 23-hour complement of `10:00..11:00`), so a wraparound range is indistinguishable from accidentally swapped fields — no validation can tell intent from typo. This is a mathematical property of circular domains, not an implementation gap.
21
+
22
+ The validator therefore refuses `:time` range columns and raises `OverlapValidator::UnsupportedColumnType` — use datetime columns instead, or split windows that cross midnight into two records.
23
+
24
+ But cyclicity is a property of the domain, not the column type — ⚠️ user-encoded cyclic domains hide inside perfectly linear columns, where no guard can see them:
25
+
26
+ - day-of-week as integer (0..6): a Friday-to-Monday shift range `5..1` wraps — same pathology as `22:00..02:00`, stored in an innocent `:integer` column
27
+ - month numbers (1..12): a November-to-February season range `11..2`
28
+ - ISO week numbers: a range from week 52 to week 2 across New Year
29
+ - angles / compass headings (0..360): a heading sector `350..10`
30
+ - longitude (−180°..+180°)
31
+ - hour-of-day as integer — people re-implement `:time` in an int column all the time
32
+ - time-of-day (24-hour clock values without a date component)
33
+
34
+ If your domain is cyclic, the validator will silently give wrong answers for wrapping ranges. Restructure the data instead: split wrapping ranges into two linear records, or lift the values into a linear domain (e.g. datetime instead of time-of-day).
35
+
36
+ To catch inverted ranges loudly instead of silently (for any column type), pair the overlap validation with an order check on your model, e.g. `validates :ends_at, comparison: { greater_than: :starts_at }`.
37
+
38
+ ## Ruby / Rails Compatibility
39
+
40
+ Every combination below is verified on every push by the [CI matrix](https://github.com/tilo/validates_overlap/actions):
6
41
 
7
- #### This gem is compatible with Rails 6. If you are looking for version compatible with Rails 3,4,5 please use version 0.8.6 .
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 |
8
50
 
9
- #### When this gem should be helpful for you?
10
- Ideal solution for booking applications where you want to make sure, that one place can be booked only once in specific time period.
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.
11
52
 
12
- #### Using
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
13
56
 
14
57
  Add to your gemfile
15
58
 
@@ -40,8 +83,29 @@ validates :starts_at, :ends_at, :overlap => {:exclude_edges => ["starts_at", "en
40
83
 
41
84
  #### shift edges
42
85
 
86
+ The shifts move the record's own range edges before the overlap check, so you can require a gap between records — or tolerate a bounded overlap:
87
+
43
88
  ```ruby
89
+ # widen the range: records must be at least 1 day apart (gap enforced)
44
90
  validates :starts_at, :ends_at, :overlap => {:start_shift => -1.day, :end_shift => 1.day}
91
+
92
+ # shrink the range: up to 2 days of overlap are accepted
93
+ validates :starts_at, :ends_at, :overlap => {:start_shift => 2.days, :end_shift => -2.days}
94
+ ```
95
+
96
+ #### non-date ranges
97
+
98
+ The overlap check runs on plain SQL comparisons, so any orderable column type works — for example integer ranges (no two records may claim overlapping number blocks), decimal ranges (price bands), or string ranges (alphabetical partitions). A nil endpoint means the range is open-ended on that side, for these types too, and the shifts work for numeric ranges as well (e.g. an integer gap or overlap tolerance). The test suite covers `date`, `datetime`, `timestamp`, `integer`, `decimal`, and `string` range columns.
99
+
100
+ ```ruby
101
+ class TicketBlock < ActiveRecord::Base
102
+ validates :number_start, :number_end, :overlap => true
103
+ end
104
+
105
+ TicketBlock.create!(number_start: 100, number_end: 199)
106
+ TicketBlock.new(number_start: 150, number_end: 250).valid? # => false (overlaps)
107
+ TicketBlock.new(number_start: 150, number_end: nil).valid? # => false (open-ended, overlaps)
108
+ TicketBlock.new(number_start: 200, number_end: 299).valid? # => true
45
109
  ```
46
110
 
47
111
  #### define custom validation key(s) and message
@@ -53,7 +117,7 @@ validates :starts_at, :ends_at, :overlap => {:message_title => [:start_at, :end_
53
117
 
54
118
  #### with complicated relations
55
119
 
56
- Example describes valildatation of user, positions and time slots.
120
+ Example describes validation of user, positions and time slots.
57
121
  User can't be assigned 2 times on position which is under time slot with time overlap.
58
122
 
59
123
  ```ruby
@@ -90,3 +154,12 @@ class ActiveMeeting < ActiveRecord::Base
90
154
  end
91
155
 
92
156
  ```
157
+
158
+ ## Maintainership
159
+
160
+ `validates_overlap` was created by [Robin Bortlik](https://github.com/robinbortlik), who built and maintained it starting 2011.
161
+ Since August 2026 the gem is maintained by [Tilo Sloboda](https://github.com/tilo).
162
+
163
+ A big thank you to Robin for creating this awesome gem and for the years of work he put into it. ❤️
164
+
165
+
@@ -3,12 +3,9 @@ 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
8
-
9
- attr_accessor :sql_conditions
10
- attr_accessor :sql_values
11
- attr_accessor :scoped_model
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
12
9
 
13
10
  def initialize(args)
14
11
  attributes_are_range(args[:attributes])
@@ -16,11 +13,15 @@ class OverlapValidator < ActiveModel::EachValidator
16
13
  super
17
14
  end
18
15
 
16
+ # NOTE: Rails registers ONE validator instance per model class, shared by every
17
+ # validation of that class (including concurrent ones) — so the query being
18
+ # built must never be stored on the validator itself (issue #50)
19
19
  def validate(record)
20
- initialize_query(record, options)
21
- if overlapped_exists?
20
+ reject_unsupported_column_types(record)
21
+ relation, sql_conditions, sql_values = initialize_query(record, options)
22
+ if overlapped_exists?(relation, sql_conditions, sql_values)
22
23
  if options[:load_overlapped]
23
- record.instance_variable_set(:@overlapped_records, get_overlapped)
24
+ record.instance_variable_set(:@overlapped_records, get_overlapped(relation, sql_conditions, sql_values))
24
25
  end
25
26
 
26
27
  if record.respond_to? attributes.first
@@ -39,22 +40,41 @@ class OverlapValidator < ActiveModel::EachValidator
39
40
 
40
41
  protected
41
42
 
43
+ # Time-of-day is a cyclic domain: every pair of values denotes some valid
44
+ # range there, so wraparound intent is indistinguishable from accidentally
45
+ # swapped fields — refuse loudly instead of answering wrong (see README).
46
+ # Checked at validate time, not at class-definition time, because column
47
+ # metadata must not be touched while migrations may still be pending.
48
+ def reject_unsupported_column_types(record)
49
+ return unless record.class.respond_to?(:columns_hash)
50
+ attributes.each do |attr|
51
+ next if attr.to_s.include?('.')
52
+ column = record.class.columns_hash[attr.to_s]
53
+ next unless column && column.type == :time
54
+ 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
+ end
56
+ end
57
+
58
+ # Build the complete overlap query for this record.
59
+ # return array in form [relation, sql_conditions, sql_values]
42
60
  def initialize_query(record, options = {})
43
61
  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?
62
+ relation = scoped_model.default_scoped
63
+ sql_values = generate_overlap_sql_values(record)
64
+ sql_conditions, primary_key_values = generate_overlap_sql_conditions(record, sql_values)
65
+ sql_values = sql_values.merge(primary_key_values)
66
+ sql_conditions, sql_values = add_attributes(record, options[:scope], sql_conditions, sql_values) if options && options[:scope].present?
67
+ relation = add_query_options(relation, options[:query_options]) if options && options[:query_options].present?
68
+ [relation, sql_conditions, sql_values]
49
69
  end
50
70
 
51
71
  # 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])
72
+ def overlapped_exists?(relation, sql_conditions, sql_values)
73
+ relation.exists?([sql_conditions, sql_values])
54
74
  end
55
75
 
56
- def get_overlapped
57
- scoped_model.where([sql_conditions, sql_values])
76
+ def get_overlapped(relation, sql_conditions, sql_values)
77
+ relation.where([sql_conditions, sql_values])
58
78
  end
59
79
 
60
80
  # Resolve attributes values from record to use in sql conditions
@@ -108,55 +128,70 @@ class OverlapValidator < ActiveModel::EachValidator
108
128
  record.send(primary_key_name)
109
129
  end
110
130
 
111
- # Generate sql condition for time range cross
112
- def generate_overlap_sql_conditions(record)
131
+ # Generate sql condition for time range cross; a persisted record is excluded
132
+ # from the comparison by its primary key, passed as a bind value
133
+ # return array in form [sql_conditions, sql_values]
134
+ def generate_overlap_sql_conditions(record, sql_values)
113
135
  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)
136
+ main_condition = condition_string(starts_at_attr, ends_at_attr, sql_values)
117
137
  if record.new_record?
118
- self.sql_conditions = main_condition
138
+ [main_condition, {}]
119
139
  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
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 }]
122
142
  end
123
143
  end
124
144
 
125
- # Return hash of values for overlap sql condition
145
+ # Return hash of values for overlap sql condition; a nil endpoint means the
146
+ # record's range is open-ended on that side — no value is emitted for it and
147
+ # condition_string drops the corresponding comparison
148
+ # NOTE: shifts are only applied when configured — unconditionally adding a
149
+ # default of 0 would raise a TypeError for non-numeric types such as String
126
150
  def generate_overlap_sql_values(record)
127
151
  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 }
152
+ start_shift = options && options[:start_shift]
153
+ end_shift = options && options[:end_shift]
154
+ starts_at_value += start_shift if starts_at_value && start_shift
155
+ ends_at_value += end_shift if ends_at_value && end_shift
156
+ sql_values = {}
157
+ sql_values[:starts_at_value] = starts_at_value if starts_at_value
158
+ sql_values[:ends_at_value] = ends_at_value if ends_at_value
159
+ sql_values
131
160
  end
132
161
 
133
162
  # Return the condition string depend on exclude_edges option.
134
- def condition_string(starts_at_attr, ends_at_attr)
163
+ # A comparison is only emitted for endpoints the record actually has: an
164
+ # open-ended side matches every other record by definition, so its clause is
165
+ # dropped (a record with both endpoints nil overlaps everything)
166
+ def condition_string(starts_at_attr, ends_at_attr, sql_values)
135
167
  except_option = Array(options[:exclude_edges]).map(&:to_s)
136
168
  starts_at_sign = except_option.include?(starts_at_attr.to_s.split('.').last) ? '<' : '<='
137
169
  ends_at_sign = except_option.include?(ends_at_attr.to_s.split('.').last) ? '>' : '>='
138
170
  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 ')
171
+ query << "(#{ends_at_attr} IS NULL OR #{ends_at_attr} #{ends_at_sign} :starts_at_value)" if sql_values.key?(:starts_at_value)
172
+ query << "(#{starts_at_attr} IS NULL OR #{starts_at_attr} #{starts_at_sign} :ends_at_value)" if sql_values.key?(:ends_at_value)
173
+ query.empty? ? '1 = 1' : query.join(' AND ')
142
174
  end
143
175
 
144
176
  # Add attributes and values to sql conditions.
145
177
  # 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)
178
+ # return array in form [sql_conditions, sql_values]
179
+ def add_attributes(record, attrs, sql_conditions, sql_values)
147
180
  if attrs.is_a?(Array)
148
- attrs.each { |attr| add_attribute(record, attr) }
181
+ attrs.each { |attr| sql_conditions, sql_values = add_attribute(record, attr, sql_conditions, sql_values) }
149
182
  elsif attrs.is_a?(Hash)
150
183
  attrs.each do |attr_name, value|
151
- add_attribute(record, attr_name, value)
184
+ sql_conditions, sql_values = add_attribute(record, attr_name, sql_conditions, sql_values, value)
152
185
  end
153
186
  else
154
- add_attribute(record, attrs)
187
+ sql_conditions, sql_values = add_attribute(record, attrs, sql_conditions, sql_values)
155
188
  end
189
+ [sql_conditions, sql_values]
156
190
  end
157
191
 
158
192
  # Add attribute and his value to sql condition
159
- def add_attribute(record, attr_name, value = nil)
193
+ # return array in form [sql_conditions, sql_values]
194
+ def add_attribute(record, attr_name, sql_conditions, sql_values, value = nil)
160
195
  _value = resolve_attribute_value(record, attr_name, value)
161
196
  operator = if _value.nil?
162
197
  ' IS NULL'
@@ -166,8 +201,9 @@ class OverlapValidator < ActiveModel::EachValidator
166
201
  ' = :%s'
167
202
  end
168
203
 
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)
204
+ sql_conditions += " AND #{attribute_to_sql(attr_name, record)} #{operator}" % value_attribute_name(attr_name)
205
+ sql_values = sql_values.merge(:"#{value_attribute_name(attr_name)}" => _value)
206
+ [sql_conditions, sql_values]
171
207
  end
172
208
 
173
209
  def value_attribute_name(attr_name)
@@ -182,7 +218,7 @@ class OverlapValidator < ActiveModel::EachValidator
182
218
  value = record.read_attribute(attr_name)
183
219
 
184
220
  if is_enum_attribute?(record, attr_name)
185
- value = record.class.defined_enums[attr_name][value]
221
+ value = record.class.defined_enums[attr_name.to_s][value]
186
222
  end
187
223
 
188
224
  value
@@ -200,9 +236,11 @@ class OverlapValidator < ActiveModel::EachValidator
200
236
  # Allow to use scope, joins, includes methods before querying
201
237
  # == Example:
202
238
  # validates_overlap :date_from, :date_to, :query_options => {:includes => "visits"}
203
- def add_query_options(methods)
239
+ # return the relation with the query options applied
240
+ def add_query_options(relation, methods)
204
241
  methods.each do |method_name, params|
205
- self.scoped_model = scoped_model.send(method_name.to_sym, *params)
242
+ relation = relation.send(method_name.to_sym, *params)
206
243
  end
244
+ relation
207
245
  end
208
246
  end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ValidatesOverlap
4
+ VERSION = '1.2.0'
5
+ end
@@ -1 +1,2 @@
1
- require File.join(File.dirname(__FILE__), 'validates_overlap', 'overlap_validator')
1
+ require_relative 'validates_overlap/version'
2
+ require_relative 'validates_overlap/overlap_validator'