time_range_uniqueness 1.0.2 → 1.0.3
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 +2 -0
- data/CHANGELOG.md +9 -0
- data/README.md +2 -0
- data/lib/time_range_uniqueness/constraint_naming.rb +19 -0
- data/lib/time_range_uniqueness/migration_additions.rb +2 -10
- data/lib/time_range_uniqueness/model_additions.rb +64 -0
- data/lib/time_range_uniqueness/version.rb +1 -1
- data/lib/time_range_uniqueness.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c9c0a85c6dc8bcf5a87ec0bc0bc5ba71cd33e74431ae8b614ee3c317f53137e2
|
|
4
|
+
data.tar.gz: 9e783861728a33574463cd47748bacbbba82141ff642b60d29a5af419fcd8954
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 125a522c47ec9c1a985b7ebb573190e42561b1f96adc560ed0b5733aa54ea5cbab4026ff4c4eb5eee04331aaeb895276105b3719a0d940855c4c643cbf3c5aeb
|
|
7
|
+
data.tar.gz: c80909df16e9628c5fe0013847db4207abf0b906ea109a0df0878aba6be80f03fa51d1442604e6e54b0a9da14b08635b04f962dfa07f5a06cdf0425cd1e12c28
|
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.0.3] - 2026-06-05
|
|
4
|
+
|
|
5
|
+
- Translate the database-level exclusion-constraint violation into a validation error instead of
|
|
6
|
+
letting it surface as an unhandled `ActiveRecord::StatementInvalid`. This closes the gap left by
|
|
7
|
+
the model validation's check-then-insert: a conflict introduced by a concurrent write or by
|
|
8
|
+
`save(validate: false)` now adds the overlap error to the time range column (`save` returns
|
|
9
|
+
`false`, `save!` raises `ActiveRecord::RecordInvalid`). Unrelated database errors still propagate.
|
|
10
|
+
- Run the test suite against ActiveRecord 7.1, 7.2, and 8.0 in CI to back the supported version range.
|
|
11
|
+
|
|
3
12
|
## [1.0.2] - 2026-05-31
|
|
4
13
|
|
|
5
14
|
- Update the README to match the current behavior: document the Ruby >= 3.2 and
|
data/README.md
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
It adds support for creating exclusion constraints on PostgreSQL `tstzrange` columns and validates the uniqueness of time ranges in models.
|
|
6
6
|
|
|
7
|
+
[](https://badge.fury.io/rb/time_range_uniqueness)
|
|
7
8
|
[](https://github.com/j-boers-13/time_range_uniqueness/actions/workflows/ci.yml)
|
|
8
9
|
|
|
9
10
|
## Features
|
|
@@ -12,6 +13,7 @@ It adds support for creating exclusion constraints on PostgreSQL `tstzrange` col
|
|
|
12
13
|
- **Model Additions**: Adds validation to ensure time ranges do not overlap with existing records.
|
|
13
14
|
- Supports optional scoping to ensure time ranges are unique within specified contexts (e.g., unique per event name).
|
|
14
15
|
- Honors the time range's bound inclusivity (`..` vs `...`) so the model validation agrees with the database-level exclusion constraint.
|
|
16
|
+
- Translates a database-level exclusion-constraint violation (e.g. from a concurrent write or `save(validate: false)`) into a validation error instead of an unhandled exception.
|
|
15
17
|
- Treats a `NULL` scope value as never-conflicting, matching PostgreSQL's exclusion-constraint semantics (`NULL = NULL` is never true).
|
|
16
18
|
- Works with models that use a composite primary key.
|
|
17
19
|
- Keeps generated constraint names within PostgreSQL's 63-character identifier limit, and raises if a custom `:name` exceeds it.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module TimeRangeUniqueness
|
|
6
|
+
MAX_IDENTIFIER_LENGTH = 63
|
|
7
|
+
|
|
8
|
+
module ConstraintNaming
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def default_constraint_name(table, scope_columns, time_range_column)
|
|
12
|
+
name = "exclude_#{table}_on_#{[scope_columns, time_range_column].flatten.join('_')}"
|
|
13
|
+
return name if name.length <= MAX_IDENTIFIER_LENGTH
|
|
14
|
+
|
|
15
|
+
digest = Digest::SHA256.hexdigest(name)[0, 10]
|
|
16
|
+
"#{name[0, MAX_IDENTIFIER_LENGTH - digest.length - 1]}_#{digest}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'digest'
|
|
4
|
-
|
|
5
3
|
module TimeRangeUniqueness
|
|
6
4
|
# This module provides methods for adding and managing time range uniqueness
|
|
7
5
|
# constraints in ActiveRecord migrations.
|
|
@@ -34,7 +32,7 @@ module TimeRangeUniqueness
|
|
|
34
32
|
COLUMN_TYPE = :tstzrange
|
|
35
33
|
|
|
36
34
|
# PostgreSQL truncates identifiers to 63 bytes (NAMEDATALEN - 1).
|
|
37
|
-
MAX_IDENTIFIER_LENGTH =
|
|
35
|
+
MAX_IDENTIFIER_LENGTH = TimeRangeUniqueness::MAX_IDENTIFIER_LENGTH
|
|
38
36
|
|
|
39
37
|
# Adds a time range column and an exclusion constraint to the specified table.
|
|
40
38
|
#
|
|
@@ -104,13 +102,7 @@ module TimeRangeUniqueness
|
|
|
104
102
|
# @param time_range_column [Symbol] The time range column name.
|
|
105
103
|
# @return [String] The generated constraint name.
|
|
106
104
|
def generate_constraint_name(table, scope_columns, time_range_column)
|
|
107
|
-
|
|
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}"
|
|
105
|
+
TimeRangeUniqueness::ConstraintNaming.default_constraint_name(table, scope_columns, time_range_column)
|
|
114
106
|
end
|
|
115
107
|
|
|
116
108
|
# Ensures the btree_gist extension is enabled.
|
|
@@ -54,12 +54,76 @@ module TimeRangeUniqueness
|
|
|
54
54
|
scope_columns = Array(options[:scope])
|
|
55
55
|
message = options[:message] || 'overlaps with an existing record'
|
|
56
56
|
|
|
57
|
+
TimeRangeUniqueness::ModelAdditions.register_constraint(self, time_range_column, scope_columns, message,
|
|
58
|
+
options[:name])
|
|
59
|
+
|
|
57
60
|
validate do
|
|
58
61
|
overlapping = TimeRangeUniqueness::ModelAdditions.overlapping?(self, time_range_column, scope_columns)
|
|
59
62
|
errors.add(time_range_column, message) if overlapping
|
|
60
63
|
end
|
|
61
64
|
end
|
|
62
65
|
|
|
66
|
+
def self.register_constraint(model, time_range_column, scope_columns, message, name)
|
|
67
|
+
unless model.respond_to?(:time_range_uniqueness_constraints)
|
|
68
|
+
model.class_attribute :time_range_uniqueness_constraints, instance_accessor: false, default: []
|
|
69
|
+
model.prepend(ViolationHandling)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
model.time_range_uniqueness_constraints += [
|
|
73
|
+
{ column: time_range_column, scope_columns: scope_columns, message: message, name: name }
|
|
74
|
+
]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.violation_constraint_name(error)
|
|
78
|
+
cause = error.cause
|
|
79
|
+
return nil unless defined?(PG::ExclusionViolation) && cause.is_a?(PG::ExclusionViolation)
|
|
80
|
+
|
|
81
|
+
result = cause.respond_to?(:result) ? cause.result : nil
|
|
82
|
+
result&.error_field(PG::Result::PG_DIAG_CONSTRAINT_NAME)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.constraint_name_for(model, config)
|
|
86
|
+
return config[:name].to_s if config[:name]
|
|
87
|
+
|
|
88
|
+
TimeRangeUniqueness::ConstraintNaming.default_constraint_name(
|
|
89
|
+
model.table_name, config[:scope_columns], config[:column]
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.matched_constraint(record, error)
|
|
94
|
+
name = violation_constraint_name(error)
|
|
95
|
+
return unless name
|
|
96
|
+
|
|
97
|
+
record.class.time_range_uniqueness_constraints.find do |config|
|
|
98
|
+
constraint_name_for(record.class, config) == name
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
module ViolationHandling
|
|
103
|
+
def save(...)
|
|
104
|
+
super
|
|
105
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
106
|
+
apply_time_range_uniqueness_error(e)
|
|
107
|
+
false
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def save!(...)
|
|
111
|
+
super
|
|
112
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
113
|
+
apply_time_range_uniqueness_error(e)
|
|
114
|
+
raise ActiveRecord::RecordInvalid, self
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def apply_time_range_uniqueness_error(error)
|
|
120
|
+
config = TimeRangeUniqueness::ModelAdditions.matched_constraint(self, error)
|
|
121
|
+
raise error unless config
|
|
122
|
+
|
|
123
|
+
errors.add(config[:column], config[:message])
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
63
127
|
# Checks whether the record's time range overlaps any other record, optionally scoped.
|
|
64
128
|
#
|
|
65
129
|
# @param record [ActiveRecord::Base] The record being validated.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: time_range_uniqueness
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0.
|
|
4
|
+
version: 1.0.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- j-boers-13
|
|
@@ -62,6 +62,7 @@ files:
|
|
|
62
62
|
- README.md
|
|
63
63
|
- Rakefile
|
|
64
64
|
- lib/time_range_uniqueness.rb
|
|
65
|
+
- lib/time_range_uniqueness/constraint_naming.rb
|
|
65
66
|
- lib/time_range_uniqueness/migration_additions.rb
|
|
66
67
|
- lib/time_range_uniqueness/model_additions.rb
|
|
67
68
|
- lib/time_range_uniqueness/version.rb
|