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 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
@@ -0,0 +1,8 @@
1
+ # Default ignored files
2
+ /shelf/
3
+ /workspace.xml
4
+ # Editor-based HTTP Client requests
5
+ /httpRequests/
6
+ # Datasource local storage ignored files
7
+ /dataSources/
8
+ /dataSources.local.xml
data/.idea/misc.xml ADDED
@@ -0,0 +1,4 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="ProjectRootManager" version="2" project-jdk-name="Remote-asdf: ruby-3.3.0-p0" project-jdk-type="RUBY_SDK" />
4
+ </project>
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
@@ -0,0 +1,6 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="VcsDirectoryMappings">
4
+ <mapping directory="" vcs="Git" />
5
+ </component>
6
+ </project>
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,7 @@
1
+ require:
2
+ - rubocop-performance
3
+ - rubocop-rspec
4
+
5
+ AllCops:
6
+ TargetRubyVersion: 2.6
7
+ NewCops: enable
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2024-09-15
4
+
5
+ - Initial release
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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TimeRangeUniqueness
4
+ VERSION = '0.1.0'
5
+ 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: []