time_range_uniqueness 0.1.0 → 1.0.1
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/.rubocop.yml +20 -3
- data/.ruby-version +1 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +28 -0
- data/README.md +20 -31
- data/Rakefile +1 -4
- data/lib/time_range_uniqueness/migration_additions.rb +41 -19
- data/lib/time_range_uniqueness/model_additions.rb +39 -39
- data/lib/time_range_uniqueness/version.rb +1 -1
- data/lib/time_range_uniqueness.rb +0 -1
- metadata +19 -105
- data/.idea/.gitignore +0 -8
- data/.idea/misc.xml +0 -4
- data/.idea/modules.xml +0 -8
- data/.idea/time_range_uniqueness.iml +0 -62
- data/.idea/vcs.xml +0 -6
- data/time_range_uniqueness.gemspec +0 -40
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1856dd82ab1e6aaddf8cd3c332ecbe84250218b2fae914426eba695d377c8513
|
|
4
|
+
data.tar.gz: f3d66c621fc3089986f9452bfcc02dedadd47c6b75fc914cc8b127af58dde021
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 60e9c6b37fa3f0cc8ae51903006a182970900b9e17b37afcd4a7ff57fb84f58d7d84c7657eb661da6055e81ed07328941b2ee885c2c6e74e3fbccb942ae8528e
|
|
7
|
+
data.tar.gz: b2a21bd9c93329bf320ebe9f3b36f79912ab41adfd9a9bbef057d98f1bedbf700a5e058dfbb9ac08c01b3721def1cd71589af17850e9971524954cecef12ba83
|
data/.rubocop.yml
CHANGED
|
@@ -1,7 +1,24 @@
|
|
|
1
|
-
|
|
1
|
+
plugins:
|
|
2
2
|
- rubocop-performance
|
|
3
3
|
- rubocop-rspec
|
|
4
4
|
|
|
5
5
|
AllCops:
|
|
6
|
-
TargetRubyVersion: 2
|
|
7
|
-
NewCops: enable
|
|
6
|
+
TargetRubyVersion: 3.2
|
|
7
|
+
NewCops: enable
|
|
8
|
+
SuggestExtensions: false
|
|
9
|
+
|
|
10
|
+
Style/Documentation:
|
|
11
|
+
inherit_mode:
|
|
12
|
+
merge:
|
|
13
|
+
- Exclude
|
|
14
|
+
Exclude:
|
|
15
|
+
- lib/time_range_uniqueness.rb
|
|
16
|
+
|
|
17
|
+
RSpec/BeforeAfterAll:
|
|
18
|
+
Enabled: false
|
|
19
|
+
|
|
20
|
+
RSpec/MultipleMemoizedHelpers:
|
|
21
|
+
Max: 8
|
|
22
|
+
|
|
23
|
+
RSpec/ExampleLength:
|
|
24
|
+
Max: 8
|
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.4.4
|
data/.tool-versions
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.4.4
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.0.1] - 2026-05-31
|
|
4
|
+
|
|
5
|
+
- Fix the overlap validation to treat a `NULL` scope value as never-conflicting, matching
|
|
6
|
+
the exclusion constraint (`NULL = NULL` is never true in PostgreSQL). Previously the
|
|
7
|
+
validation reported a false overlap for rows the database would accept.
|
|
8
|
+
- Support models with a composite primary key in the overlap validation. Previously the
|
|
9
|
+
validation raised `ArgumentError` when excluding the current record, so such models
|
|
10
|
+
could not be validated or created.
|
|
11
|
+
- Raise `ArgumentError` when a custom `:name` exceeds PostgreSQL's 63-character identifier
|
|
12
|
+
limit, instead of relying on the database to truncate it silently.
|
|
13
|
+
|
|
14
|
+
## [1.0.0] - 2026-05-31
|
|
15
|
+
|
|
16
|
+
- Require Ruby >= 3.2 and support ActiveRecord 7.1 through 8.x (and pg >= 1.5).
|
|
17
|
+
- Fix the overlap validation to honor the time range's bound inclusivity (`..` vs `...`)
|
|
18
|
+
so it agrees with the PostgreSQL exclusion constraint. Previously a record whose
|
|
19
|
+
inclusive endpoint touched an existing record passed validation but was rejected by
|
|
20
|
+
the database with an unhandled `ExclusionViolation`.
|
|
21
|
+
- Use the model's primary key instead of assuming a column named `id` when excluding the
|
|
22
|
+
current record from the overlap check.
|
|
23
|
+
- The model validation is now only extended onto `ActiveRecord::Base` (rather than also
|
|
24
|
+
being included), so its helpers no longer leak onto every record instance.
|
|
25
|
+
- **Breaking:** `add_time_range_uniqueness` now raises `ArgumentError` when the `:with`
|
|
26
|
+
option is omitted, matching `validates_time_range_uniqueness`.
|
|
27
|
+
- Generated constraint names are kept within PostgreSQL's 63-character identifier limit
|
|
28
|
+
(long names are truncated with a digest suffix to stay deterministic and collision-free).
|
|
29
|
+
- Quote table and column identifiers in the generated migration and validation SQL.
|
|
30
|
+
|
|
3
31
|
## [0.1.0] - 2024-09-15
|
|
4
32
|
|
|
5
33
|
- Initial release
|
data/README.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
#
|
|
1
|
+
# time_range_uniqueness
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**time_range_uniqueness** is a Ruby gem that provides ActiveRecord migrations and model validation to ensure that time ranges do not overlap within a table.
|
|
4
|
+
|
|
5
|
+
It adds support for creating exclusion constraints on PostgreSQL `tstzrange` columns and validates the uniqueness of time ranges in models.
|
|
6
|
+
|
|
7
|
+
[](https://github.com/j-boers-13/time_range_uniqueness/actions/workflows/ci.yml)
|
|
4
8
|
|
|
5
9
|
## Features
|
|
6
10
|
|
|
@@ -34,29 +38,20 @@ gem install time_range_uniqueness
|
|
|
34
38
|
|
|
35
39
|
In your migrations, you can use the `add_time_range_uniqueness` method to add a time range column with an exclusion constraint. This will prevent overlapping time ranges in your table.
|
|
36
40
|
|
|
37
|
-
```ruby
|
|
38
|
-
class AddEventTimeRangeUniqueness < ActiveRecord::Migration[6.1]
|
|
39
|
-
def change
|
|
40
|
-
add_time_range_uniqueness :events,
|
|
41
|
-
with: :event_time_range,
|
|
42
|
-
scope: :event_name, # Optional scope
|
|
43
|
-
name: 'unique_event_time_ranges' # Optional custom constraint name
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
```
|
|
47
|
-
|
|
48
41
|
#### Options:
|
|
49
42
|
- `with`: **(Required)** The name of the column that stores the time range.
|
|
50
43
|
- `scope`: **(Optional)** An array of columns to scope the uniqueness check (e.g., `:event_name`).
|
|
51
44
|
- `name`: **(Optional)** The name of the exclusion constraint. If not provided, a default name is generated.
|
|
52
|
-
- `column_type`: The time range column type will always be `tstzrange`.
|
|
53
45
|
|
|
54
46
|
### Example
|
|
55
47
|
|
|
56
48
|
```ruby
|
|
57
49
|
class AddEventTimeRangeUniqueness < ActiveRecord::Migration[6.1]
|
|
58
50
|
def change
|
|
59
|
-
add_time_range_uniqueness :events,
|
|
51
|
+
add_time_range_uniqueness :events,
|
|
52
|
+
with: :event_time_range,
|
|
53
|
+
scope: :event_name, # Optional scope
|
|
54
|
+
name: 'unique_event_time_ranges' # Optional custom constraint name
|
|
60
55
|
end
|
|
61
56
|
end
|
|
62
57
|
```
|
|
@@ -67,30 +62,24 @@ This example ensures that the `event_time_range` column in the `events` table is
|
|
|
67
62
|
|
|
68
63
|
The gem also provides model-level validation to ensure time ranges do not overlap. You can include this validation in your models like this:
|
|
69
64
|
|
|
70
|
-
```ruby
|
|
71
|
-
class Event < ApplicationRecord
|
|
72
|
-
validates_time_range_uniqueness(
|
|
73
|
-
with: :event_time_range,
|
|
74
|
-
scope: :event_name,
|
|
75
|
-
message: 'cannot overlap with an existing event'
|
|
76
|
-
)
|
|
77
|
-
end
|
|
78
|
-
```
|
|
79
|
-
|
|
80
65
|
#### Options:
|
|
81
66
|
- `with`: **(Required)** The name of the time range column to validate.
|
|
82
67
|
- `scope`: **(Optional)** An array of columns to scope the uniqueness check (e.g., `:event_name`).
|
|
83
68
|
- `message`: **(Optional)** A custom error message when validation fails (default: `'overlaps with an existing record'`).
|
|
84
69
|
|
|
85
|
-
|
|
70
|
+
#### Examples:
|
|
86
71
|
|
|
87
72
|
```ruby
|
|
88
|
-
class Event <
|
|
89
|
-
validates_time_range_uniqueness
|
|
73
|
+
class Event < ActiveRecord::Base
|
|
74
|
+
validates_time_range_uniqueness(
|
|
75
|
+
with: :event_time_range,
|
|
76
|
+
scope: :event_name,
|
|
77
|
+
message: 'cannot overlap with an existing event'
|
|
78
|
+
)
|
|
90
79
|
end
|
|
91
80
|
```
|
|
92
81
|
|
|
93
|
-
This example ensures that the `event_time_range` in the `Event` model does not overlap with other events with the same `event_name
|
|
82
|
+
This example ensures that the `event_time_range` in the `Event` model does not overlap with other events with the same `event_name` and will display the message `cannot overlap with an existing event` when it does.
|
|
94
83
|
|
|
95
84
|
### PostgreSQL Requirements
|
|
96
85
|
|
|
@@ -102,8 +91,8 @@ CREATE EXTENSION IF NOT EXISTS btree_gist;
|
|
|
102
91
|
|
|
103
92
|
## Contributing
|
|
104
93
|
|
|
105
|
-
Bug reports and pull requests are welcome on GitHub at [https://github.com/
|
|
94
|
+
Bug reports and pull requests are welcome on GitHub at [https://github.com/j-boers-13/time_range_uniqueness](https://github.com/j-boers-13/time_range_uniqueness)
|
|
106
95
|
|
|
107
96
|
## License
|
|
108
97
|
|
|
109
|
-
The gem is available as open-source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
98
|
+
The gem is available as open-source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
3
5
|
module TimeRangeUniqueness
|
|
4
6
|
# This module provides methods for adding and managing time range uniqueness
|
|
5
7
|
# constraints in ActiveRecord migrations.
|
|
@@ -14,7 +16,6 @@ module TimeRangeUniqueness
|
|
|
14
16
|
# add_time_range_uniqueness :events,
|
|
15
17
|
# with: :event_time_range,
|
|
16
18
|
# scope: :event_name,
|
|
17
|
-
# column_type: :tstzrange,
|
|
18
19
|
# name: 'unique_event_time_ranges'
|
|
19
20
|
# end
|
|
20
21
|
# end
|
|
@@ -23,7 +24,6 @@ module TimeRangeUniqueness
|
|
|
23
24
|
#
|
|
24
25
|
# * +:with+ - The name of the column that stores the time range (required).
|
|
25
26
|
# * +:scope+ - (Optional) An array of columns to scope the uniqueness check.
|
|
26
|
-
# * +:column_type+ - (Optional) The type of the time range column (default: :tstzrange).
|
|
27
27
|
# * +:name+ - (Optional) The name of the constraint.
|
|
28
28
|
#
|
|
29
29
|
# == Methods
|
|
@@ -31,6 +31,11 @@ module TimeRangeUniqueness
|
|
|
31
31
|
# * +add_time_range_uniqueness(table, options = {})+ - Adds the time range column and the exclusion constraint.
|
|
32
32
|
# * +CommandRecorder+ - Records the `add_time_range_uniqueness` command so it can be replayed during rollback.
|
|
33
33
|
module MigrationAdditions
|
|
34
|
+
COLUMN_TYPE = :tstzrange
|
|
35
|
+
|
|
36
|
+
# PostgreSQL truncates identifiers to 63 bytes (NAMEDATALEN - 1).
|
|
37
|
+
MAX_IDENTIFIER_LENGTH = 63
|
|
38
|
+
|
|
34
39
|
# Adds a time range column and an exclusion constraint to the specified table.
|
|
35
40
|
#
|
|
36
41
|
# This method creates or modifies a column to store time ranges and ensures that
|
|
@@ -40,33 +45,45 @@ module TimeRangeUniqueness
|
|
|
40
45
|
# @param options [Hash] The options for the constraint.
|
|
41
46
|
# @option options [Symbol] :with The name of the time range column.
|
|
42
47
|
# @option options [Array<Symbol>] :scope (Optional) Columns to scope the uniqueness check.
|
|
43
|
-
# @option options [Symbol] :column_type (Optional) The type of the time range column (default: :tstzrange).
|
|
44
48
|
# @option options [String] :name (Optional) The name of the constraint.
|
|
45
49
|
def add_time_range_uniqueness(table, options = {})
|
|
46
|
-
|
|
50
|
+
validate_options!(options)
|
|
51
|
+
|
|
52
|
+
time_range_column = options[:with]
|
|
47
53
|
scope_columns = Array(options[:scope])
|
|
48
|
-
column_type = :tstzrange
|
|
49
54
|
constraint_name = options[:name] || generate_constraint_name(table, scope_columns, time_range_column)
|
|
50
55
|
|
|
51
56
|
reversible do |dir|
|
|
52
|
-
dir.up { apply_up_migration(table, time_range_column,
|
|
57
|
+
dir.up { apply_up_migration(table, time_range_column, options, constraint_name, scope_columns) }
|
|
53
58
|
dir.down { apply_down_migration(table, time_range_column, constraint_name) }
|
|
54
59
|
end
|
|
55
60
|
end
|
|
56
61
|
|
|
57
62
|
private
|
|
58
63
|
|
|
64
|
+
# Validates the options passed to +add_time_range_uniqueness+.
|
|
65
|
+
#
|
|
66
|
+
# @param options [Hash] The options for the constraint.
|
|
67
|
+
# @raise [ArgumentError] If +:with+ is missing or +:name+ exceeds the identifier limit.
|
|
68
|
+
def validate_options!(options)
|
|
69
|
+
raise ArgumentError, 'You must specify the :with option with the time range column name' unless options[:with]
|
|
70
|
+
|
|
71
|
+
name = options[:name]
|
|
72
|
+
return unless name && name.to_s.length > MAX_IDENTIFIER_LENGTH
|
|
73
|
+
|
|
74
|
+
raise ArgumentError, "The :name option exceeds the #{MAX_IDENTIFIER_LENGTH}-character identifier limit"
|
|
75
|
+
end
|
|
76
|
+
|
|
59
77
|
# Applies the changes for the up migration.
|
|
60
78
|
#
|
|
61
79
|
# @param table [Symbol, String] The name of the table.
|
|
62
80
|
# @param time_range_column [Symbol] The time range column name.
|
|
63
|
-
# @param column_type [Symbol] The type of the column.
|
|
64
81
|
# @param options [Hash] Additional options for the column.
|
|
65
82
|
# @param constraint_name [String] The name of the constraint.
|
|
66
83
|
# @param scope_columns [Array<Symbol>] The columns used in the scope.
|
|
67
|
-
def apply_up_migration(table, time_range_column,
|
|
84
|
+
def apply_up_migration(table, time_range_column, options, constraint_name, scope_columns)
|
|
68
85
|
setup_extension
|
|
69
|
-
add_column_to_table(table, time_range_column,
|
|
86
|
+
add_column_to_table(table, time_range_column, options)
|
|
70
87
|
add_exclusion_constraint(table, constraint_name, scope_columns, time_range_column)
|
|
71
88
|
end
|
|
72
89
|
|
|
@@ -87,7 +104,13 @@ module TimeRangeUniqueness
|
|
|
87
104
|
# @param time_range_column [Symbol] The time range column name.
|
|
88
105
|
# @return [String] The generated constraint name.
|
|
89
106
|
def generate_constraint_name(table, scope_columns, time_range_column)
|
|
90
|
-
"exclude_#{table}_on_#{[scope_columns, time_range_column].flatten.join('_')}"
|
|
107
|
+
name = "exclude_#{table}_on_#{[scope_columns, time_range_column].flatten.join('_')}"
|
|
108
|
+
return name if name.length <= MAX_IDENTIFIER_LENGTH
|
|
109
|
+
|
|
110
|
+
# Keep the name deterministic and within PostgreSQL's limit so the up and down
|
|
111
|
+
# migrations refer to the same constraint. A digest avoids collisions after truncation.
|
|
112
|
+
digest = Digest::SHA256.hexdigest(name)[0, 10]
|
|
113
|
+
"#{name[0, MAX_IDENTIFIER_LENGTH - digest.length - 1]}_#{digest}"
|
|
91
114
|
end
|
|
92
115
|
|
|
93
116
|
# Ensures the btree_gist extension is enabled.
|
|
@@ -99,12 +122,11 @@ module TimeRangeUniqueness
|
|
|
99
122
|
#
|
|
100
123
|
# @param table [Symbol, String] The name of the table.
|
|
101
124
|
# @param time_range_column [Symbol] The time range column name.
|
|
102
|
-
# @param column_type [Symbol] The type of the column.
|
|
103
125
|
# @param options [Hash] Additional options for the column.
|
|
104
|
-
def add_column_to_table(table, time_range_column,
|
|
126
|
+
def add_column_to_table(table, time_range_column, options)
|
|
105
127
|
return if column_exists?(table, time_range_column)
|
|
106
128
|
|
|
107
|
-
add_column table, time_range_column,
|
|
129
|
+
add_column table, time_range_column, COLUMN_TYPE, **options.slice(:null, :default)
|
|
108
130
|
end
|
|
109
131
|
|
|
110
132
|
# Adds an exclusion constraint to the table.
|
|
@@ -114,13 +136,13 @@ module TimeRangeUniqueness
|
|
|
114
136
|
# @param scope_columns [Array<Symbol>] The columns used in the scope.
|
|
115
137
|
# @param time_range_column [Symbol] The time range column name.
|
|
116
138
|
def add_exclusion_constraint(table, constraint_name, scope_columns, time_range_column)
|
|
117
|
-
columns = scope_columns.map { |col| "#{col} WITH =" }
|
|
118
|
-
columns << "#{time_range_column} WITH &&"
|
|
139
|
+
columns = scope_columns.map { |col| "#{quote_column_name(col)} WITH =" }
|
|
140
|
+
columns << "#{quote_column_name(time_range_column)} WITH &&"
|
|
119
141
|
expression = columns.join(', ')
|
|
120
142
|
|
|
121
143
|
execute <<-SQL
|
|
122
|
-
ALTER TABLE #{table}
|
|
123
|
-
ADD CONSTRAINT #{constraint_name}
|
|
144
|
+
ALTER TABLE #{quote_table_name(table)}
|
|
145
|
+
ADD CONSTRAINT #{quote_column_name(constraint_name)}
|
|
124
146
|
EXCLUDE USING GIST (#{expression});
|
|
125
147
|
SQL
|
|
126
148
|
end
|
|
@@ -131,8 +153,8 @@ module TimeRangeUniqueness
|
|
|
131
153
|
# @param constraint_name [String] The name of the constraint.
|
|
132
154
|
def remove_exclusion_constraint(table, constraint_name)
|
|
133
155
|
execute <<-SQL
|
|
134
|
-
ALTER TABLE #{table}
|
|
135
|
-
DROP CONSTRAINT IF EXISTS #{constraint_name};
|
|
156
|
+
ALTER TABLE #{quote_table_name(table)}
|
|
157
|
+
DROP CONSTRAINT IF EXISTS #{quote_column_name(constraint_name)};
|
|
136
158
|
SQL
|
|
137
159
|
end
|
|
138
160
|
|
|
@@ -4,8 +4,8 @@ module TimeRangeUniqueness
|
|
|
4
4
|
# The `ModelAdditions` module provides a custom validation for ensuring that time ranges
|
|
5
5
|
# in ActiveRecord models are unique across records, optionally scoped by other columns.
|
|
6
6
|
#
|
|
7
|
-
# This module is
|
|
8
|
-
# validation
|
|
7
|
+
# This module is extended onto ActiveRecord::Base so that models gain a
|
|
8
|
+
# validation method to check for overlapping time ranges between records.
|
|
9
9
|
#
|
|
10
10
|
# == Example
|
|
11
11
|
#
|
|
@@ -31,10 +31,10 @@ module TimeRangeUniqueness
|
|
|
31
31
|
# == Methods
|
|
32
32
|
#
|
|
33
33
|
# * +validates_time_range_uniqueness+ - Adds a validation for time range uniqueness.
|
|
34
|
-
# * +
|
|
35
|
-
# * +
|
|
34
|
+
# * +ModelAdditions.overlapping?+ - Internal helper that checks for overlapping time ranges.
|
|
35
|
+
# * +ModelAdditions.scoped_relation+ - Internal helper that builds the scoped relation.
|
|
36
36
|
#
|
|
37
|
-
#
|
|
37
|
+
# Extending this onto ActiveRecord::Base adds the ability to ensure that
|
|
38
38
|
# the specified time range does not overlap with other records' time ranges, optionally
|
|
39
39
|
# scoped by additional fields.
|
|
40
40
|
module ModelAdditions
|
|
@@ -52,53 +52,53 @@ module TimeRangeUniqueness
|
|
|
52
52
|
|
|
53
53
|
time_range_column = options[:with]
|
|
54
54
|
scope_columns = Array(options[:scope])
|
|
55
|
+
message = options[:message] || 'overlaps with an existing record'
|
|
55
56
|
|
|
56
|
-
|
|
57
|
+
validate do
|
|
58
|
+
overlapping = TimeRangeUniqueness::ModelAdditions.overlapping?(self, time_range_column, scope_columns)
|
|
59
|
+
errors.add(time_range_column, message) if overlapping
|
|
60
|
+
end
|
|
57
61
|
end
|
|
58
62
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
# Defines the validation logic for ensuring time range uniqueness.
|
|
62
|
-
#
|
|
63
|
-
# This method is called internally by the validation and checks whether a record's
|
|
64
|
-
# time range overlaps with any other records, optionally scoped by other columns.
|
|
63
|
+
# Checks whether the record's time range overlaps any other record, optionally scoped.
|
|
65
64
|
#
|
|
65
|
+
# @param record [ActiveRecord::Base] The record being validated.
|
|
66
66
|
# @param time_range_column [Symbol] The name of the time range column.
|
|
67
67
|
# @param scope_columns [Array<Symbol>] The columns to scope the uniqueness check.
|
|
68
|
-
# @
|
|
69
|
-
def
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
# @return [Boolean] True if there is an overlap, false otherwise.
|
|
69
|
+
def self.overlapping?(record, time_range_column, scope_columns)
|
|
70
|
+
time_range = record.public_send(time_range_column)
|
|
71
|
+
return false if time_range.nil?
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
# A NULL scope value can never satisfy the exclusion constraint's `=` comparison,
|
|
74
|
+
# so such a record can never conflict at the database level. Mirror that here.
|
|
75
|
+
return false if scope_columns.any? { |col| record.public_send(col).nil? }
|
|
74
76
|
|
|
75
|
-
|
|
77
|
+
column = record.class.connection.quote_column_name(time_range_column)
|
|
78
|
+
bounds = time_range.exclude_end? ? '[)' : '[]'
|
|
76
79
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
+
scoped_relation(record, scope_columns)
|
|
81
|
+
.where("#{column} && tstzrange(?, ?, ?)", time_range.begin, time_range.end, bounds)
|
|
82
|
+
.exists?
|
|
83
|
+
end
|
|
80
84
|
|
|
81
|
-
|
|
85
|
+
# Builds the set of other records to check against, optionally scoped by the given columns.
|
|
86
|
+
#
|
|
87
|
+
# @param record [ActiveRecord::Base] The record being validated.
|
|
88
|
+
# @param scope_columns [Array<Symbol>] The columns to scope the uniqueness check.
|
|
89
|
+
# @return [ActiveRecord::Relation] All other records, scoped by the given columns.
|
|
90
|
+
def self.scoped_relation(record, scope_columns)
|
|
91
|
+
klass = record.class
|
|
92
|
+
# Pair each primary key column with its value so this works for both single
|
|
93
|
+
# and composite primary keys (record.id is an array for composite keys).
|
|
94
|
+
excluded = Array(klass.primary_key).zip(Array(record.id)).to_h
|
|
95
|
+
relation = klass.where.not(excluded)
|
|
82
96
|
|
|
83
|
-
|
|
97
|
+
scope_columns.each do |col|
|
|
98
|
+
relation = relation.where(col => record.public_send(col))
|
|
84
99
|
end
|
|
85
|
-
end
|
|
86
100
|
|
|
87
|
-
|
|
88
|
-
#
|
|
89
|
-
# This method performs the actual overlap check by querying the database using the
|
|
90
|
-
# GiST index for range data types in PostgreSQL.
|
|
91
|
-
#
|
|
92
|
-
# @param relation [ActiveRecord::Relation] The scope of records to check against.
|
|
93
|
-
# @param time_range_column [Symbol] The name of the time range column.
|
|
94
|
-
# @param time_range [Range] The time range to check for overlap.
|
|
95
|
-
# @return [Boolean] True if there is an overlap, false otherwise.
|
|
96
|
-
def time_range_column_overlapping?(relation, time_range_column, time_range)
|
|
97
|
-
relation.where(
|
|
98
|
-
"#{time_range_column} && tstzrange(?, ?, '[)')",
|
|
99
|
-
time_range.begin,
|
|
100
|
-
time_range.end
|
|
101
|
-
).exists?
|
|
101
|
+
relation
|
|
102
102
|
end
|
|
103
103
|
end
|
|
104
104
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: time_range_uniqueness
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1
|
|
4
|
+
version: 1.0.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- j-boers-13
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: activerecord
|
|
@@ -16,119 +15,37 @@ dependencies:
|
|
|
16
15
|
requirements:
|
|
17
16
|
- - ">="
|
|
18
17
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: '
|
|
18
|
+
version: '7.1'
|
|
20
19
|
- - "<"
|
|
21
20
|
- !ruby/object:Gem::Version
|
|
22
|
-
version: '
|
|
21
|
+
version: '9.0'
|
|
23
22
|
type: :runtime
|
|
24
23
|
prerelease: false
|
|
25
24
|
version_requirements: !ruby/object:Gem::Requirement
|
|
26
25
|
requirements:
|
|
27
26
|
- - ">="
|
|
28
27
|
- !ruby/object:Gem::Version
|
|
29
|
-
version: '
|
|
28
|
+
version: '7.1'
|
|
30
29
|
- - "<"
|
|
31
30
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: '
|
|
31
|
+
version: '9.0'
|
|
33
32
|
- !ruby/object:Gem::Dependency
|
|
34
33
|
name: pg
|
|
35
34
|
requirement: !ruby/object:Gem::Requirement
|
|
36
35
|
requirements:
|
|
37
36
|
- - ">="
|
|
38
37
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: '
|
|
38
|
+
version: '1.5'
|
|
40
39
|
type: :runtime
|
|
41
40
|
prerelease: false
|
|
42
41
|
version_requirements: !ruby/object:Gem::Requirement
|
|
43
42
|
requirements:
|
|
44
43
|
- - ">="
|
|
45
44
|
- !ruby/object:Gem::Version
|
|
46
|
-
version: '
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
requirements:
|
|
51
|
-
- - ">="
|
|
52
|
-
- !ruby/object:Gem::Version
|
|
53
|
-
version: '0'
|
|
54
|
-
type: :development
|
|
55
|
-
prerelease: false
|
|
56
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
57
|
-
requirements:
|
|
58
|
-
- - ">="
|
|
59
|
-
- !ruby/object:Gem::Version
|
|
60
|
-
version: '0'
|
|
61
|
-
- !ruby/object:Gem::Dependency
|
|
62
|
-
name: rake
|
|
63
|
-
requirement: !ruby/object:Gem::Requirement
|
|
64
|
-
requirements:
|
|
65
|
-
- - ">="
|
|
66
|
-
- !ruby/object:Gem::Version
|
|
67
|
-
version: '0'
|
|
68
|
-
type: :development
|
|
69
|
-
prerelease: false
|
|
70
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
71
|
-
requirements:
|
|
72
|
-
- - ">="
|
|
73
|
-
- !ruby/object:Gem::Version
|
|
74
|
-
version: '0'
|
|
75
|
-
- !ruby/object:Gem::Dependency
|
|
76
|
-
name: rspec
|
|
77
|
-
requirement: !ruby/object:Gem::Requirement
|
|
78
|
-
requirements:
|
|
79
|
-
- - ">="
|
|
80
|
-
- !ruby/object:Gem::Version
|
|
81
|
-
version: '0'
|
|
82
|
-
type: :development
|
|
83
|
-
prerelease: false
|
|
84
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
85
|
-
requirements:
|
|
86
|
-
- - ">="
|
|
87
|
-
- !ruby/object:Gem::Version
|
|
88
|
-
version: '0'
|
|
89
|
-
- !ruby/object:Gem::Dependency
|
|
90
|
-
name: rubocop
|
|
91
|
-
requirement: !ruby/object:Gem::Requirement
|
|
92
|
-
requirements:
|
|
93
|
-
- - ">="
|
|
94
|
-
- !ruby/object:Gem::Version
|
|
95
|
-
version: '0'
|
|
96
|
-
type: :development
|
|
97
|
-
prerelease: false
|
|
98
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
99
|
-
requirements:
|
|
100
|
-
- - ">="
|
|
101
|
-
- !ruby/object:Gem::Version
|
|
102
|
-
version: '0'
|
|
103
|
-
- !ruby/object:Gem::Dependency
|
|
104
|
-
name: rubocop-performance
|
|
105
|
-
requirement: !ruby/object:Gem::Requirement
|
|
106
|
-
requirements:
|
|
107
|
-
- - ">="
|
|
108
|
-
- !ruby/object:Gem::Version
|
|
109
|
-
version: '0'
|
|
110
|
-
type: :development
|
|
111
|
-
prerelease: false
|
|
112
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
113
|
-
requirements:
|
|
114
|
-
- - ">="
|
|
115
|
-
- !ruby/object:Gem::Version
|
|
116
|
-
version: '0'
|
|
117
|
-
- !ruby/object:Gem::Dependency
|
|
118
|
-
name: rubocop-rspec
|
|
119
|
-
requirement: !ruby/object:Gem::Requirement
|
|
120
|
-
requirements:
|
|
121
|
-
- - ">="
|
|
122
|
-
- !ruby/object:Gem::Version
|
|
123
|
-
version: '0'
|
|
124
|
-
type: :development
|
|
125
|
-
prerelease: false
|
|
126
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
127
|
-
requirements:
|
|
128
|
-
- - ">="
|
|
129
|
-
- !ruby/object:Gem::Version
|
|
130
|
-
version: '0'
|
|
131
|
-
description:
|
|
45
|
+
version: '1.5'
|
|
46
|
+
description: This gem helps you easily set up time range uniqueness constraints in
|
|
47
|
+
PostgreSQL using ActiveRecord migrations and validations. It ensures that time ranges
|
|
48
|
+
do not overlap within a table, supporting optional scoping of uniqueness.
|
|
132
49
|
email:
|
|
133
50
|
- jeroen.boers1@gmail.com
|
|
134
51
|
executables: []
|
|
@@ -136,13 +53,10 @@ extensions: []
|
|
|
136
53
|
extra_rdoc_files:
|
|
137
54
|
- README.md
|
|
138
55
|
files:
|
|
139
|
-
- ".idea/.gitignore"
|
|
140
|
-
- ".idea/misc.xml"
|
|
141
|
-
- ".idea/modules.xml"
|
|
142
|
-
- ".idea/time_range_uniqueness.iml"
|
|
143
|
-
- ".idea/vcs.xml"
|
|
144
56
|
- ".rspec"
|
|
145
57
|
- ".rubocop.yml"
|
|
58
|
+
- ".ruby-version"
|
|
59
|
+
- ".tool-versions"
|
|
146
60
|
- CHANGELOG.md
|
|
147
61
|
- LICENSE.txt
|
|
148
62
|
- README.md
|
|
@@ -151,13 +65,14 @@ files:
|
|
|
151
65
|
- lib/time_range_uniqueness/migration_additions.rb
|
|
152
66
|
- lib/time_range_uniqueness/model_additions.rb
|
|
153
67
|
- lib/time_range_uniqueness/version.rb
|
|
154
|
-
-
|
|
155
|
-
homepage:
|
|
68
|
+
homepage: https://github.com/j-boers-13/time_range_uniqueness
|
|
156
69
|
licenses:
|
|
157
70
|
- MIT
|
|
158
71
|
metadata:
|
|
72
|
+
source_code_uri: https://github.com/j-boers-13/time_range_uniqueness
|
|
73
|
+
homepage_uri: https://github.com/j-boers-13/time_range_uniqueness
|
|
74
|
+
changelog_uri: https://github.com/j-boers-13/time_range_uniqueness/CHANGELOG.md
|
|
159
75
|
rubygems_mfa_required: 'true'
|
|
160
|
-
post_install_message:
|
|
161
76
|
rdoc_options: []
|
|
162
77
|
require_paths:
|
|
163
78
|
- lib
|
|
@@ -165,15 +80,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
165
80
|
requirements:
|
|
166
81
|
- - ">="
|
|
167
82
|
- !ruby/object:Gem::Version
|
|
168
|
-
version: 2.
|
|
83
|
+
version: 3.2.0
|
|
169
84
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
170
85
|
requirements:
|
|
171
86
|
- - ">="
|
|
172
87
|
- !ruby/object:Gem::Version
|
|
173
88
|
version: '0'
|
|
174
89
|
requirements: []
|
|
175
|
-
rubygems_version: 3.
|
|
176
|
-
signing_key:
|
|
90
|
+
rubygems_version: 3.6.7
|
|
177
91
|
specification_version: 4
|
|
178
92
|
summary: Easily set up time range uniqueness in Ruby On Rails.
|
|
179
93
|
test_files: []
|
data/.idea/.gitignore
DELETED
data/.idea/misc.xml
DELETED
data/.idea/modules.xml
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
-
<project version="4">
|
|
3
|
-
<component name="ProjectModuleManager">
|
|
4
|
-
<modules>
|
|
5
|
-
<module fileurl="file://$PROJECT_DIR$/.idea/time_range_uniqueness.iml" filepath="$PROJECT_DIR$/.idea/time_range_uniqueness.iml" />
|
|
6
|
-
</modules>
|
|
7
|
-
</component>
|
|
8
|
-
</project>
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
-
<module type="RUBY_MODULE" version="4">
|
|
3
|
-
<component name="ModuleRunConfigurationManager">
|
|
4
|
-
<shared />
|
|
5
|
-
</component>
|
|
6
|
-
<component name="NewModuleRootManager">
|
|
7
|
-
<content url="file://$MODULE_DIR$">
|
|
8
|
-
<sourceFolder url="file://$MODULE_DIR$/features" isTestSource="true" />
|
|
9
|
-
<sourceFolder url="file://$MODULE_DIR$/spec" isTestSource="true" />
|
|
10
|
-
<sourceFolder url="file://$MODULE_DIR$/test" isTestSource="true" />
|
|
11
|
-
</content>
|
|
12
|
-
<orderEntry type="inheritedJdk" />
|
|
13
|
-
<orderEntry type="sourceFolder" forTests="false" />
|
|
14
|
-
<orderEntry type="library" scope="PROVIDED" name="ast (v2.4.2, Remote-asdf: ruby-3.3.0-p0) [gem]" level="application" />
|
|
15
|
-
<orderEntry type="library" scope="PROVIDED" name="bundler (v2.5.5, Remote-asdf: ruby-3.3.0-p0) [gem]" level="application" />
|
|
16
|
-
<orderEntry type="library" scope="PROVIDED" name="language_server-protocol (v3.17.0.3, Remote-asdf: ruby-3.3.0-p0) [gem]" level="application" />
|
|
17
|
-
<orderEntry type="library" scope="PROVIDED" name="rainbow (v3.1.1, Remote-asdf: ruby-3.3.0-p0) [gem]" level="application" />
|
|
18
|
-
<orderEntry type="library" scope="PROVIDED" name="ruby-progressbar (v1.13.0, Remote-asdf: ruby-3.3.0-p0) [gem]" level="application" />
|
|
19
|
-
<orderEntry type="library" scope="PROVIDED" name="tzinfo (v2.0.6, Remote-asdf: ruby-3.3.0-p0) [gem]" level="application" />
|
|
20
|
-
</component>
|
|
21
|
-
<component name="RakeTasksCache-v2">
|
|
22
|
-
<option name="myRootTask">
|
|
23
|
-
<RakeTaskImpl id="rake">
|
|
24
|
-
<subtasks>
|
|
25
|
-
<RakeTaskImpl description="Build time_range_uniqueness-0.1.0.gem into the pkg directory" fullCommand="build" id="build" />
|
|
26
|
-
<RakeTaskImpl id="build">
|
|
27
|
-
<subtasks>
|
|
28
|
-
<RakeTaskImpl description="Generate SHA512 checksum if time_range_uniqueness-0.1.0.gem into the checksums directory" fullCommand="build:checksum" id="checksum" />
|
|
29
|
-
</subtasks>
|
|
30
|
-
</RakeTaskImpl>
|
|
31
|
-
<RakeTaskImpl description="Remove any temporary products" fullCommand="clean" id="clean" />
|
|
32
|
-
<RakeTaskImpl description="Remove any generated files" fullCommand="clobber" id="clobber" />
|
|
33
|
-
<RakeTaskImpl description="Build and install time_range_uniqueness-0.1.0.gem into system gems" fullCommand="install" id="install" />
|
|
34
|
-
<RakeTaskImpl id="install">
|
|
35
|
-
<subtasks>
|
|
36
|
-
<RakeTaskImpl description="Build and install time_range_uniqueness-0.1.0.gem into system gems without network access" fullCommand="install:local" id="local" />
|
|
37
|
-
</subtasks>
|
|
38
|
-
</RakeTaskImpl>
|
|
39
|
-
<RakeTaskImpl description="Create tag v0.1.0 and build and push time_range_uniqueness-0.1.0.gem to rubygems.org" fullCommand="release[remote]" id="release[remote]" />
|
|
40
|
-
<RakeTaskImpl description="Run RuboCop" fullCommand="rubocop" id="rubocop" />
|
|
41
|
-
<RakeTaskImpl id="rubocop">
|
|
42
|
-
<subtasks>
|
|
43
|
-
<RakeTaskImpl description="Autocorrect RuboCop offenses (only when it's safe)" fullCommand="rubocop:autocorrect" id="autocorrect" />
|
|
44
|
-
<RakeTaskImpl description="Autocorrect RuboCop offenses (safe and unsafe)" fullCommand="rubocop:autocorrect_all" id="autocorrect_all" />
|
|
45
|
-
<RakeTaskImpl description="" fullCommand="rubocop:auto_correct" id="auto_correct" />
|
|
46
|
-
</subtasks>
|
|
47
|
-
</RakeTaskImpl>
|
|
48
|
-
<RakeTaskImpl description="Run RSpec code examples" fullCommand="spec" id="spec" />
|
|
49
|
-
<RakeTaskImpl description="" fullCommand="default" id="default" />
|
|
50
|
-
<RakeTaskImpl description="" fullCommand="release" id="release" />
|
|
51
|
-
<RakeTaskImpl id="release">
|
|
52
|
-
<subtasks>
|
|
53
|
-
<RakeTaskImpl description="" fullCommand="release:guard_clean" id="guard_clean" />
|
|
54
|
-
<RakeTaskImpl description="" fullCommand="release:rubygem_push" id="rubygem_push" />
|
|
55
|
-
<RakeTaskImpl description="" fullCommand="release:source_control_push" id="source_control_push" />
|
|
56
|
-
</subtasks>
|
|
57
|
-
</RakeTaskImpl>
|
|
58
|
-
</subtasks>
|
|
59
|
-
</RakeTaskImpl>
|
|
60
|
-
</option>
|
|
61
|
-
</component>
|
|
62
|
-
</module>
|
data/.idea/vcs.xml
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative 'lib/time_range_uniqueness/version'
|
|
4
|
-
|
|
5
|
-
Gem::Specification.new do |spec|
|
|
6
|
-
spec.name = 'time_range_uniqueness'
|
|
7
|
-
spec.version = TimeRangeUniqueness::VERSION
|
|
8
|
-
spec.authors = ['j-boers-13']
|
|
9
|
-
spec.email = ['jeroen.boers1@gmail.com']
|
|
10
|
-
|
|
11
|
-
spec.summary = 'Easily set up time range uniqueness in Ruby On Rails.'
|
|
12
|
-
# spec.description = "TODO: Write a longer description or delete this line."
|
|
13
|
-
# spec.homepage = "TODO: Put your gem's website or public repo URL here."
|
|
14
|
-
spec.license = 'MIT'
|
|
15
|
-
spec.required_ruby_version = '>= 2.6.0'
|
|
16
|
-
|
|
17
|
-
# Specify which files should be added to the gem when it is released.
|
|
18
|
-
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
19
|
-
spec.files = Dir.chdir(__dir__) do
|
|
20
|
-
`git ls-files -z`.split("\x0").reject do |f|
|
|
21
|
-
(File.expand_path(f) == __FILE__) ||
|
|
22
|
-
f.start_with?(*%w[bin/ test/ spec/ features/ .git Gemfile])
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
spec.extra_rdoc_files = ['README.md']
|
|
26
|
-
spec.bindir = 'exe'
|
|
27
|
-
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
28
|
-
spec.require_paths = ['lib']
|
|
29
|
-
|
|
30
|
-
spec.add_dependency 'activerecord', '>= 5.2', '< 8.0'
|
|
31
|
-
spec.add_dependency 'pg', '>= 0.18'
|
|
32
|
-
|
|
33
|
-
spec.add_development_dependency 'dotenv'
|
|
34
|
-
spec.add_development_dependency 'rake'
|
|
35
|
-
spec.add_development_dependency 'rspec'
|
|
36
|
-
spec.add_development_dependency 'rubocop'
|
|
37
|
-
spec.add_development_dependency 'rubocop-performance'
|
|
38
|
-
spec.add_development_dependency 'rubocop-rspec'
|
|
39
|
-
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
40
|
-
end
|