time_range_uniqueness 0.1.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 +7 -0
- data/.idea/.gitignore +8 -0
- data/.idea/misc.xml +4 -0
- data/.idea/modules.xml +8 -0
- data/.idea/time_range_uniqueness.iml +62 -0
- data/.idea/vcs.xml +6 -0
- data/.rspec +3 -0
- data/.rubocop.yml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +109 -0
- data/Rakefile +15 -0
- data/lib/time_range_uniqueness/migration_additions.rb +161 -0
- data/lib/time_range_uniqueness/model_additions.rb +104 -0
- data/lib/time_range_uniqueness/version.rb +5 -0
- data/lib/time_range_uniqueness.rb +20 -0
- data/time_range_uniqueness.gemspec +40 -0
- metadata +179 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 5d6fd29dd2746a4129bb39f795c097fe4052eb0fa7dbf9d8c6398d8c35ff4cb4
|
4
|
+
data.tar.gz: 9b58e702e5797b25b633eda09e77c9fd0090ba1313de7d1c25453a54584d1ff3
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 85d7de7b1f1a78f47bad178fdba8d2b27c39c4322cfc528e92436985c5f451488b9a92c2a14c6945b5b5d31315f940fad718091c73958ec98ec3696a79f9c7f5
|
7
|
+
data.tar.gz: c62fd7916b188a288e0d5ed8c0d5c97b7bad16811173ff11614a6302b3809a90eaf2293ac5677abb5a8b269114f1897f9d73082f38403877a0d6b909227474e3
|
data/.idea/.gitignore
ADDED
data/.idea/misc.xml
ADDED
data/.idea/modules.xml
ADDED
@@ -0,0 +1,8 @@
|
|
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>
|
@@ -0,0 +1,62 @@
|
|
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
ADDED
data/.rspec
ADDED
data/.rubocop.yml
ADDED
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2024 j-boers-13
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,109 @@
|
|
1
|
+
# TimeRangeUniqueness
|
2
|
+
|
3
|
+
**TimeRangeUniqueness** is a Ruby gem that provides ActiveRecord migrations and model validation to ensure that time ranges do not overlap within a table. It adds support for creating exclusion constraints on PostgreSQL `tstzrange` columns and validates the uniqueness of time ranges in models.
|
4
|
+
|
5
|
+
## Features
|
6
|
+
|
7
|
+
- **Migration Additions**: Adds a custom method for generating exclusion constraints on time range columns in PostgreSQL using `tstzrange`.
|
8
|
+
- **Model Additions**: Adds validation to ensure time ranges do not overlap with existing records.
|
9
|
+
- Supports optional scoping to ensure time ranges are unique within specified contexts (e.g., unique per event name).
|
10
|
+
|
11
|
+
## Installation
|
12
|
+
|
13
|
+
Add this line to your application's Gemfile:
|
14
|
+
|
15
|
+
```ruby
|
16
|
+
gem 'time_range_uniqueness'
|
17
|
+
```
|
18
|
+
|
19
|
+
Then execute:
|
20
|
+
|
21
|
+
```bash
|
22
|
+
bundle install
|
23
|
+
```
|
24
|
+
|
25
|
+
Or install it yourself as:
|
26
|
+
|
27
|
+
```bash
|
28
|
+
gem install time_range_uniqueness
|
29
|
+
```
|
30
|
+
|
31
|
+
## Usage
|
32
|
+
|
33
|
+
### Migration Additions
|
34
|
+
|
35
|
+
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
|
+
|
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
|
+
#### Options:
|
49
|
+
- `with`: **(Required)** The name of the column that stores the time range.
|
50
|
+
- `scope`: **(Optional)** An array of columns to scope the uniqueness check (e.g., `:event_name`).
|
51
|
+
- `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
|
+
|
54
|
+
### Example
|
55
|
+
|
56
|
+
```ruby
|
57
|
+
class AddEventTimeRangeUniqueness < ActiveRecord::Migration[6.1]
|
58
|
+
def change
|
59
|
+
add_time_range_uniqueness :events, with: :event_time_range, scope: :event_name
|
60
|
+
end
|
61
|
+
end
|
62
|
+
```
|
63
|
+
|
64
|
+
This example ensures that the `event_time_range` column in the `events` table is unique within the scope of the `event_name` column.
|
65
|
+
|
66
|
+
### Model Additions
|
67
|
+
|
68
|
+
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
|
+
|
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
|
+
#### Options:
|
81
|
+
- `with`: **(Required)** The name of the time range column to validate.
|
82
|
+
- `scope`: **(Optional)** An array of columns to scope the uniqueness check (e.g., `:event_name`).
|
83
|
+
- `message`: **(Optional)** A custom error message when validation fails (default: `'overlaps with an existing record'`).
|
84
|
+
|
85
|
+
### Example
|
86
|
+
|
87
|
+
```ruby
|
88
|
+
class Event < ApplicationRecord
|
89
|
+
validates_time_range_uniqueness with: :event_time_range, scope: :event_name
|
90
|
+
end
|
91
|
+
```
|
92
|
+
|
93
|
+
This example ensures that the `event_time_range` in the `Event` model does not overlap with other events with the same `event_name`.
|
94
|
+
|
95
|
+
### PostgreSQL Requirements
|
96
|
+
|
97
|
+
Ensure that your PostgreSQL instance has the `btree_gist` extension enabled. The gem will automatically attempt to enable this extension when applying the migration.
|
98
|
+
|
99
|
+
```sql
|
100
|
+
CREATE EXTENSION IF NOT EXISTS btree_gist;
|
101
|
+
```
|
102
|
+
|
103
|
+
## Contributing
|
104
|
+
|
105
|
+
Bug reports and pull requests are welcome on GitHub at [https://github.com/your_username/time_range_uniqueness](https://github.com/your_username/time_range_uniqueness).
|
106
|
+
|
107
|
+
## License
|
108
|
+
|
109
|
+
The gem is available as open-source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'bundler/gem_tasks'
|
4
|
+
require 'rspec/core/rake_task'
|
5
|
+
|
6
|
+
RSpec::Core::RakeTask.new(:spec)
|
7
|
+
|
8
|
+
require 'rubocop/rake_task'
|
9
|
+
|
10
|
+
RuboCop::RakeTask.new do |task|
|
11
|
+
task.requires << 'rubocop-performance'
|
12
|
+
task.requires << 'rubocop-rspec'
|
13
|
+
end
|
14
|
+
|
15
|
+
task default: %i[spec rubocop]
|
@@ -0,0 +1,161 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TimeRangeUniqueness
|
4
|
+
# This module provides methods for adding and managing time range uniqueness
|
5
|
+
# constraints in ActiveRecord migrations.
|
6
|
+
#
|
7
|
+
# It allows you to add an exclusion constraint to ensure that time ranges do not
|
8
|
+
# overlap within a table.
|
9
|
+
#
|
10
|
+
# == Example
|
11
|
+
#
|
12
|
+
# class AddEventTimeRangeUniqueness < ActiveRecord::Migration[6.1]
|
13
|
+
# def change
|
14
|
+
# add_time_range_uniqueness :events,
|
15
|
+
# with: :event_time_range,
|
16
|
+
# scope: :event_name,
|
17
|
+
# column_type: :tstzrange,
|
18
|
+
# name: 'unique_event_time_ranges'
|
19
|
+
# end
|
20
|
+
# end
|
21
|
+
#
|
22
|
+
# == Options
|
23
|
+
#
|
24
|
+
# * +:with+ - The name of the column that stores the time range (required).
|
25
|
+
# * +: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
|
+
# * +:name+ - (Optional) The name of the constraint.
|
28
|
+
#
|
29
|
+
# == Methods
|
30
|
+
#
|
31
|
+
# * +add_time_range_uniqueness(table, options = {})+ - Adds the time range column and the exclusion constraint.
|
32
|
+
# * +CommandRecorder+ - Records the `add_time_range_uniqueness` command so it can be replayed during rollback.
|
33
|
+
module MigrationAdditions
|
34
|
+
# Adds a time range column and an exclusion constraint to the specified table.
|
35
|
+
#
|
36
|
+
# This method creates or modifies a column to store time ranges and ensures that
|
37
|
+
# no two time ranges overlap for records with the same scoped columns.
|
38
|
+
#
|
39
|
+
# @param table [Symbol, String] The name of the table to which the time range uniqueness constraint will be added.
|
40
|
+
# @param options [Hash] The options for the constraint.
|
41
|
+
# @option options [Symbol] :with The name of the time range column.
|
42
|
+
# @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
|
+
# @option options [String] :name (Optional) The name of the constraint.
|
45
|
+
def add_time_range_uniqueness(table, options = {})
|
46
|
+
time_range_column = options[:with] || :time_range
|
47
|
+
scope_columns = Array(options[:scope])
|
48
|
+
column_type = :tstzrange
|
49
|
+
constraint_name = options[:name] || generate_constraint_name(table, scope_columns, time_range_column)
|
50
|
+
|
51
|
+
reversible do |dir|
|
52
|
+
dir.up { apply_up_migration(table, time_range_column, column_type, options, constraint_name, scope_columns) }
|
53
|
+
dir.down { apply_down_migration(table, time_range_column, constraint_name) }
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
# Applies the changes for the up migration.
|
60
|
+
#
|
61
|
+
# @param table [Symbol, String] The name of the table.
|
62
|
+
# @param time_range_column [Symbol] The time range column name.
|
63
|
+
# @param column_type [Symbol] The type of the column.
|
64
|
+
# @param options [Hash] Additional options for the column.
|
65
|
+
# @param constraint_name [String] The name of the constraint.
|
66
|
+
# @param scope_columns [Array<Symbol>] The columns used in the scope.
|
67
|
+
def apply_up_migration(table, time_range_column, column_type, options, constraint_name, scope_columns)
|
68
|
+
setup_extension
|
69
|
+
add_column_to_table(table, time_range_column, column_type, options)
|
70
|
+
add_exclusion_constraint(table, constraint_name, scope_columns, time_range_column)
|
71
|
+
end
|
72
|
+
|
73
|
+
# Applies the changes for the down migration.
|
74
|
+
#
|
75
|
+
# @param table [Symbol, String] The name of the table.
|
76
|
+
# @param time_range_column [Symbol] The time range column name.
|
77
|
+
# @param constraint_name [String] The name of the constraint.
|
78
|
+
def apply_down_migration(table, time_range_column, constraint_name)
|
79
|
+
remove_exclusion_constraint(table, constraint_name)
|
80
|
+
remove_column_from_table(table, time_range_column)
|
81
|
+
end
|
82
|
+
|
83
|
+
# Generates a default constraint name based on table and columns.
|
84
|
+
#
|
85
|
+
# @param table [Symbol, String] The name of the table.
|
86
|
+
# @param scope_columns [Array<Symbol>] The columns used in the scope.
|
87
|
+
# @param time_range_column [Symbol] The time range column name.
|
88
|
+
# @return [String] The generated constraint name.
|
89
|
+
def generate_constraint_name(table, scope_columns, time_range_column)
|
90
|
+
"exclude_#{table}_on_#{[scope_columns, time_range_column].flatten.join('_')}"
|
91
|
+
end
|
92
|
+
|
93
|
+
# Ensures the btree_gist extension is enabled.
|
94
|
+
def setup_extension
|
95
|
+
enable_extension 'btree_gist' unless extension_enabled?('btree_gist')
|
96
|
+
end
|
97
|
+
|
98
|
+
# Adds a column to the table if it does not exist.
|
99
|
+
#
|
100
|
+
# @param table [Symbol, String] The name of the table.
|
101
|
+
# @param time_range_column [Symbol] The time range column name.
|
102
|
+
# @param column_type [Symbol] The type of the column.
|
103
|
+
# @param options [Hash] Additional options for the column.
|
104
|
+
def add_column_to_table(table, time_range_column, column_type, options)
|
105
|
+
return if column_exists?(table, time_range_column)
|
106
|
+
|
107
|
+
add_column table, time_range_column, column_type, **options.slice(:null, :default)
|
108
|
+
end
|
109
|
+
|
110
|
+
# Adds an exclusion constraint to the table.
|
111
|
+
#
|
112
|
+
# @param table [Symbol, String] The name of the table.
|
113
|
+
# @param constraint_name [String] The name of the constraint.
|
114
|
+
# @param scope_columns [Array<Symbol>] The columns used in the scope.
|
115
|
+
# @param time_range_column [Symbol] The time range column name.
|
116
|
+
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 &&"
|
119
|
+
expression = columns.join(', ')
|
120
|
+
|
121
|
+
execute <<-SQL
|
122
|
+
ALTER TABLE #{table}
|
123
|
+
ADD CONSTRAINT #{constraint_name}
|
124
|
+
EXCLUDE USING GIST (#{expression});
|
125
|
+
SQL
|
126
|
+
end
|
127
|
+
|
128
|
+
# Removes an exclusion constraint from the table.
|
129
|
+
#
|
130
|
+
# @param table [Symbol, String] The name of the table.
|
131
|
+
# @param constraint_name [String] The name of the constraint.
|
132
|
+
def remove_exclusion_constraint(table, constraint_name)
|
133
|
+
execute <<-SQL
|
134
|
+
ALTER TABLE #{table}
|
135
|
+
DROP CONSTRAINT IF EXISTS #{constraint_name};
|
136
|
+
SQL
|
137
|
+
end
|
138
|
+
|
139
|
+
# Removes a column from the table if it exists.
|
140
|
+
#
|
141
|
+
# @param table [Symbol, String] The name of the table.
|
142
|
+
# @param time_range_column [Symbol] The time range column name.
|
143
|
+
def remove_column_from_table(table, time_range_column)
|
144
|
+
remove_column table, time_range_column if column_exists?(table, time_range_column)
|
145
|
+
end
|
146
|
+
|
147
|
+
# This module extends the ActiveRecord::Migration::CommandRecorder to record
|
148
|
+
# the custom `add_time_range_uniqueness` command so that it can be replayed
|
149
|
+
# during rollback operations.
|
150
|
+
module CommandRecorder
|
151
|
+
# Records the `add_time_range_uniqueness` command.
|
152
|
+
#
|
153
|
+
# @param table [Symbol, String] The name of the table.
|
154
|
+
# @param options [Hash] The options for the constraint.
|
155
|
+
def add_time_range_uniqueness(table, options = {})
|
156
|
+
# Record the command so it can be replayed during rollback
|
157
|
+
record(:add_time_range_uniqueness, table, options)
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TimeRangeUniqueness
|
4
|
+
# The `ModelAdditions` module provides a custom validation for ensuring that time ranges
|
5
|
+
# in ActiveRecord models are unique across records, optionally scoped by other columns.
|
6
|
+
#
|
7
|
+
# This module is intended to be included in ActiveRecord models and used to add
|
8
|
+
# validation methods to check for overlapping time ranges between records.
|
9
|
+
#
|
10
|
+
# == Example
|
11
|
+
#
|
12
|
+
# class Event < ApplicationRecord
|
13
|
+
# validates_time_range_uniqueness(
|
14
|
+
# with: :event_time_range,
|
15
|
+
# scope: :event_name,
|
16
|
+
# message: 'cannot overlap with an existing event'
|
17
|
+
# )
|
18
|
+
# end
|
19
|
+
#
|
20
|
+
# This example ensures that the `event_time_range` column in the `Event` model does not overlap
|
21
|
+
# with other records having the same `event_name`. If a new event's time range overlaps, an
|
22
|
+
# error is added to the `event_time_range` field.
|
23
|
+
#
|
24
|
+
# == Options
|
25
|
+
#
|
26
|
+
# * +:with+ - The name of the time range column (required).
|
27
|
+
# * +:scope+ - (Optional) An array of columns to scope the uniqueness check (e.g., event name).
|
28
|
+
# * +:message+ - (Optional) A custom error message when validation fails. Defaults to
|
29
|
+
# 'overlaps with an existing record' if not provided.
|
30
|
+
#
|
31
|
+
# == Methods
|
32
|
+
#
|
33
|
+
# * +validates_time_range_uniqueness+ - Adds a validation for time range uniqueness.
|
34
|
+
# * +validate_records+ - Internal method to perform the validation.
|
35
|
+
# * +time_range_column_overlapping?+ - Internal method to check for overlapping time ranges.
|
36
|
+
#
|
37
|
+
# When included in an ActiveRecord model, this module adds the ability to ensure that
|
38
|
+
# the specified time range does not overlap with other records' time ranges, optionally
|
39
|
+
# scoped by additional fields.
|
40
|
+
module ModelAdditions
|
41
|
+
# Adds a custom validation method to ensure that the specified time range column
|
42
|
+
# is unique across all records, optionally scoped by other columns.
|
43
|
+
#
|
44
|
+
# Raises an ArgumentError if the +:with+ option is not specified.
|
45
|
+
#
|
46
|
+
# @param options [Hash] The options for the validation.
|
47
|
+
# @option options [Symbol] :with The name of the time range column.
|
48
|
+
# @option options [Array<Symbol>] :scope (Optional) Columns to scope the uniqueness check.
|
49
|
+
# @option options [String] :message (Optional) Custom error message when validation fails.
|
50
|
+
def validates_time_range_uniqueness(options = {})
|
51
|
+
raise ArgumentError, 'You must specify the :with option with the time range column name' unless options[:with]
|
52
|
+
|
53
|
+
time_range_column = options[:with]
|
54
|
+
scope_columns = Array(options[:scope])
|
55
|
+
|
56
|
+
validate_records(time_range_column, scope_columns, options)
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
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.
|
65
|
+
#
|
66
|
+
# @param time_range_column [Symbol] The name of the time range column.
|
67
|
+
# @param scope_columns [Array<Symbol>] The columns to scope the uniqueness check.
|
68
|
+
# @param options [Hash] The options for the validation.
|
69
|
+
def validate_records(time_range_column, scope_columns, options)
|
70
|
+
validate do
|
71
|
+
time_range = public_send(time_range_column)
|
72
|
+
|
73
|
+
next if time_range.nil?
|
74
|
+
|
75
|
+
relation = self.class.where.not(id: id)
|
76
|
+
|
77
|
+
scope_columns.each do |col|
|
78
|
+
relation = relation.where(col => public_send(col))
|
79
|
+
end
|
80
|
+
|
81
|
+
overlapping = time_range_column_overlapping?(relation, time_range_column, time_range)
|
82
|
+
|
83
|
+
errors.add(time_range_column, options[:message] || 'overlaps with an existing record') if overlapping
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
# Checks if the given time range overlaps with any existing records.
|
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?
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'time_range_uniqueness/version'
|
4
|
+
require 'active_record'
|
5
|
+
require_relative 'time_range_uniqueness/migration_additions'
|
6
|
+
require_relative 'time_range_uniqueness/model_additions'
|
7
|
+
|
8
|
+
module TimeRangeUniqueness
|
9
|
+
class Error < StandardError; end
|
10
|
+
|
11
|
+
ActiveRecord::Migration::CommandRecorder.include TimeRangeUniqueness::MigrationAdditions::CommandRecorder
|
12
|
+
|
13
|
+
# Include migration additions into ActiveRecord::Migration
|
14
|
+
ActiveRecord::Migration.include TimeRangeUniqueness::MigrationAdditions
|
15
|
+
end
|
16
|
+
|
17
|
+
ActiveSupport.on_load(:active_record) do
|
18
|
+
ActiveRecord::Base.extend TimeRangeUniqueness::ModelAdditions
|
19
|
+
ActiveRecord::Base.include TimeRangeUniqueness::ModelAdditions
|
20
|
+
end
|
@@ -0,0 +1,40 @@
|
|
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
|
metadata
ADDED
@@ -0,0 +1,179 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: time_range_uniqueness
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- j-boers-13
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2024-09-15 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activerecord
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '5.2'
|
20
|
+
- - "<"
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: '8.0'
|
23
|
+
type: :runtime
|
24
|
+
prerelease: false
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
26
|
+
requirements:
|
27
|
+
- - ">="
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '5.2'
|
30
|
+
- - "<"
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '8.0'
|
33
|
+
- !ruby/object:Gem::Dependency
|
34
|
+
name: pg
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - ">="
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '0.18'
|
40
|
+
type: :runtime
|
41
|
+
prerelease: false
|
42
|
+
version_requirements: !ruby/object:Gem::Requirement
|
43
|
+
requirements:
|
44
|
+
- - ">="
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: '0.18'
|
47
|
+
- !ruby/object:Gem::Dependency
|
48
|
+
name: dotenv
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
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:
|
132
|
+
email:
|
133
|
+
- jeroen.boers1@gmail.com
|
134
|
+
executables: []
|
135
|
+
extensions: []
|
136
|
+
extra_rdoc_files:
|
137
|
+
- README.md
|
138
|
+
files:
|
139
|
+
- ".idea/.gitignore"
|
140
|
+
- ".idea/misc.xml"
|
141
|
+
- ".idea/modules.xml"
|
142
|
+
- ".idea/time_range_uniqueness.iml"
|
143
|
+
- ".idea/vcs.xml"
|
144
|
+
- ".rspec"
|
145
|
+
- ".rubocop.yml"
|
146
|
+
- CHANGELOG.md
|
147
|
+
- LICENSE.txt
|
148
|
+
- README.md
|
149
|
+
- Rakefile
|
150
|
+
- lib/time_range_uniqueness.rb
|
151
|
+
- lib/time_range_uniqueness/migration_additions.rb
|
152
|
+
- lib/time_range_uniqueness/model_additions.rb
|
153
|
+
- lib/time_range_uniqueness/version.rb
|
154
|
+
- time_range_uniqueness.gemspec
|
155
|
+
homepage:
|
156
|
+
licenses:
|
157
|
+
- MIT
|
158
|
+
metadata:
|
159
|
+
rubygems_mfa_required: 'true'
|
160
|
+
post_install_message:
|
161
|
+
rdoc_options: []
|
162
|
+
require_paths:
|
163
|
+
- lib
|
164
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
165
|
+
requirements:
|
166
|
+
- - ">="
|
167
|
+
- !ruby/object:Gem::Version
|
168
|
+
version: 2.6.0
|
169
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
170
|
+
requirements:
|
171
|
+
- - ">="
|
172
|
+
- !ruby/object:Gem::Version
|
173
|
+
version: '0'
|
174
|
+
requirements: []
|
175
|
+
rubygems_version: 3.5.3
|
176
|
+
signing_key:
|
177
|
+
specification_version: 4
|
178
|
+
summary: Easily set up time range uniqueness in Ruby On Rails.
|
179
|
+
test_files: []
|