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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dc737c44b6dbbf13c6781eef86f089832aaf04cda0a50c0d513f91e19536f600
4
- data.tar.gz: 1be53b987bdc21f730b2ace9ea9aa9478eef92387ecdc6508a6bfad68f90d4ac
3
+ metadata.gz: c9c0a85c6dc8bcf5a87ec0bc0bc5ba71cd33e74431ae8b614ee3c317f53137e2
4
+ data.tar.gz: 9e783861728a33574463cd47748bacbbba82141ff642b60d29a5af419fcd8954
5
5
  SHA512:
6
- metadata.gz: d6d32eb4c17f95d7d0815e692f09eed56d62aeb259a9c3b05e70fa2c78dfc59ac89fbd8127dc9bf33e29e8a5c5d2382ae21fdf07dd6ae906d9b36be3a878c4e5
7
- data.tar.gz: 889b032899cbdc7cc3fc2cb73d05b593d438ecf112561dca2926a715c713d26cf92e5224cc85a9470b55c3404426484a5c66633ad8e716f7a13365e17c910bda
6
+ metadata.gz: 125a522c47ec9c1a985b7ebb573190e42561b1f96adc560ed0b5733aa54ea5cbab4026ff4c4eb5eee04331aaeb895276105b3719a0d940855c4c643cbf3c5aeb
7
+ data.tar.gz: c80909df16e9628c5fe0013847db4207abf0b906ea109a0df0878aba6be80f03fa51d1442604e6e54b0a9da14b08635b04f962dfa07f5a06cdf0425cd1e12c28
data/.rubocop.yml CHANGED
@@ -13,6 +13,8 @@ Style/Documentation:
13
13
  - Exclude
14
14
  Exclude:
15
15
  - lib/time_range_uniqueness.rb
16
+ - lib/time_range_uniqueness/constraint_naming.rb
17
+ - lib/time_range_uniqueness/model_additions.rb
16
18
 
17
19
  RSpec/BeforeAfterAll:
18
20
  Enabled: false
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
+ [![Gem Version](https://badge.fury.io/rb/time_range_uniqueness.svg?icon=si%3Arubygems)](https://badge.fury.io/rb/time_range_uniqueness)
7
8
  [![rspec](https://github.com/j-boers-13/time_range_uniqueness/actions/workflows/ci.yml/badge.svg)](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 = 63
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
- 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}"
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.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TimeRangeUniqueness
4
- VERSION = '1.0.2'
4
+ VERSION = '1.0.3'
5
5
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'time_range_uniqueness/version'
4
4
  require 'active_record'
5
+ require_relative 'time_range_uniqueness/constraint_naming'
5
6
  require_relative 'time_range_uniqueness/migration_additions'
6
7
  require_relative 'time_range_uniqueness/model_additions'
7
8
 
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.2
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