slugifiable 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f376f3d8ed6b225b61c6bd96cf207d4fb90970a7e138bd0685571225fe750343
4
- data.tar.gz: 8d77cb7631dd2e4b7eb7be4b15e1d2fd1d0b3bcb6ddfabfcf0c505d776d50ddd
3
+ metadata.gz: 230e7c9d0a24d77af781a78aed46daf46d404aa942572c4fab12f592fd24b3f7
4
+ data.tar.gz: ab8007017ef1f1ae5ecf563efb5a188e9a10150f5d861f9fc352d1b7f9023026
5
5
  SHA512:
6
- metadata.gz: ae99e80bff43688ca9402bb2b09074a0df88c685be8974d4120b5772f648b77d583c0cae3631dbbaee711a6477f51706499337c08fa3d46cfa0ba8888b05f4d5
7
- data.tar.gz: ec724965c4aed46aa09207ef3a2cc0e761466c6097efc871ff0190d05b2833a207ee6afeb6150ca04da970036f727946c25252c574fbde4f9200779634c51d7d
6
+ metadata.gz: 4649bfbb6dc0864f4397942a1f62621c437a09dd1b3b056194d5c0e82a54fbf9bd42deb48aaf5ea5da4cc16916568bdb2491c3a0fad1b8ddcfa465315cb7d52c
7
+ data.tar.gz: db9186508f938faf89e8ca4781929e53a1e45aaea006c24a3663bec97a9c80e285b8be6dbef125d6f344de62afb2084f91c5c1177e04bedaf99744226e00c33a
data/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## [0.3.0] - 2026-02-19
2
+
3
+ - Fixed race condition: retry with new random suffix on `RecordNotUnique` instead of crashing
4
+ - Added PostgreSQL-safe savepoints for retry logic
5
+ - Added support for NOT NULL slug columns (see README for setup)
6
+ - Changed `set_slug` to use `save!` instead of `save` (raises on validation errors)
7
+ - `before_create` and `after_create` callbacks re-run on retry — keep them idempotent
8
+
1
9
  ## [0.2.0] - 2026-01-16
2
10
 
3
11
  - Added a full Minitest test suite
data/README.md CHANGED
@@ -80,9 +80,53 @@ Product.first.slug
80
80
 
81
81
  If your model has a `slug` attribute in the database, `slugifiable` will automatically generate a slug for that model upon instance creation, and save it to the DB.
82
82
 
83
- > [!IMPORTANT]
84
- > Your `slug` attribute **SHOULD NOT** have `null: false` in the migration / database. If it does, `slugifiable` will not be able to save the slug to the database, and will raise an error like `ERROR: null value in column "slug" of relation "posts" violates not-null constraint (PG::NotNullViolation)`
85
- > This is because records are created without a slug, and the slug is generated later.
83
+ ### Nullable vs NOT NULL Slug Columns
84
+
85
+ **Nullable columns (default, simpler):**
86
+ ```ruby
87
+ # migration
88
+ add_column :products, :slug, :string
89
+ add_index :products, :slug, unique: true
90
+
91
+ # model
92
+ class Product < ApplicationRecord
93
+ include Slugifiable::Model
94
+ generate_slug_based_on :name
95
+ end
96
+ ```
97
+
98
+ **NOT NULL columns (requires `before_validation` setup):**
99
+ ```ruby
100
+ # migration
101
+ add_column :products, :slug, :string, null: false
102
+ add_index :products, :slug, unique: true
103
+
104
+ # model
105
+ class Product < ApplicationRecord
106
+ include Slugifiable::Model
107
+ generate_slug_based_on :name
108
+
109
+ before_validation :ensure_slug_present, on: :create
110
+
111
+ private
112
+
113
+ def ensure_slug_present
114
+ self.slug = compute_slug if slug.blank?
115
+ end
116
+ end
117
+ ```
118
+
119
+ > [!NOTE]
120
+ > When using NOT NULL slug columns, `slugifiable` handles race conditions automatically. If two processes try to create records with the same slug simultaneously, the second one will retry with a new random suffix.
121
+
122
+ > [!CAUTION]
123
+ > For NOT NULL slug columns, retries re-run the `around_create` callback chain. If a slug collision happens, your `before_create` callbacks will run again. Keep `before_create` callbacks idempotent (or move side effects to `after_create`/background jobs).
124
+
125
+ > [!NOTE]
126
+ > Retry handling is DB-constraint-driven (`RecordNotUnique`), not validation-driven. A validation-layer race that raises `RecordInvalid` will bubble up.
127
+
128
+ > [!NOTE]
129
+ > Slug collision detection matches errors containing `slug`/`_on_slug`. If your index uses a custom name that does not include those patterns, retries will not trigger and the exception will bubble up.
86
130
 
87
131
  If you're generating slugs based off the model `id`, you can also set a desired length:
88
132
  ```ruby
@@ -33,11 +33,12 @@ module Slugifiable
33
33
  # 10^18 fits safely in a 64-bit integer
34
34
  MAX_NUMBER_LENGTH = 18
35
35
 
36
- # Maximum number of attempts to generate a unique slug
37
- # before falling back to timestamp-based suffix
36
+ # Maximum number of attempts to generate a unique slug before raising.
37
+ # Also used by generate_unique_slug for EXISTS? check loop (with timestamp fallback).
38
38
  MAX_SLUG_GENERATION_ATTEMPTS = 10
39
39
 
40
40
  included do
41
+ around_create :retry_create_on_slug_unique_violation
41
42
  after_create :set_slug
42
43
  after_find :update_slug_if_nil
43
44
  validates :slug, uniqueness: true
@@ -138,6 +139,53 @@ module Slugifiable
138
139
 
139
140
  private
140
141
 
142
+ # S1: around_create retry for NOT NULL slug columns (pre-INSERT collision)
143
+ # S2: Uses savepoint (requires_new: true) for PostgreSQL compatibility
144
+ # S4: Skips overhead when slug column is nullable (no INSERT-time collision possible)
145
+ def retry_create_on_slug_unique_violation
146
+ return yield unless slug_persisted? && slug_column_not_null?
147
+
148
+ with_slug_retry(for_insert: true) do |attempts|
149
+ # `around_create` retries can re-enter create callbacks. Set a fresh slug
150
+ # before retrying so pre-insert slug strategies don't reuse a collided value.
151
+ self.slug = compute_slug if attempts.positive?
152
+ yield
153
+ end
154
+ end
155
+
156
+ # S3: Shared retry helper with savepoints for both paths
157
+ # PostgreSQL requires savepoints: without them, retry fails with
158
+ # "current transaction is aborted, commands ignored until end"
159
+ def with_slug_retry(for_insert: false)
160
+ attempts = 0
161
+ begin
162
+ self.class.transaction(requires_new: true) { yield(attempts) }
163
+ rescue ActiveRecord::RecordNotUnique => e
164
+ raise unless slug_unique_violation?(e)
165
+ raise if for_insert && persisted? # Already inserted; don't retry whole create
166
+
167
+ attempts += 1
168
+ raise if attempts >= MAX_SLUG_GENERATION_ATTEMPTS # S6: Raise on exhaustion
169
+
170
+ retry
171
+ end
172
+ end
173
+
174
+ # S4: Optimization guard - skip savepoint wrapper when slug is nullable
175
+ def slug_column_not_null?
176
+ return false unless self.class.respond_to?(:columns_hash)
177
+ column = self.class.columns_hash["slug"]
178
+ column && !column.null
179
+ end
180
+
181
+ # S5: Detect slug unique violations across PostgreSQL/MySQL/SQLite
182
+ # Pattern matches: "slug" as word boundary OR "_on_slug" (index naming convention)
183
+ # Safe false-negative for custom index names (error bubbles up instead of silent retry)
184
+ def slug_unique_violation?(error)
185
+ message = error.message.to_s.downcase
186
+ message.match?(/\bslug\b|_on_slug\b/)
187
+ end
188
+
141
189
  def normalize_length(length, default, max)
142
190
  length = length.to_i
143
191
  return default if length <= 0
@@ -170,7 +218,7 @@ module Slugifiable
170
218
 
171
219
  # If we couldn't find a unique slug after MAX_SLUG_GENERATION_ATTEMPTS,
172
220
  # append timestamp + random to ensure uniqueness
173
- if attempts == MAX_SLUG_GENERATION_ATTEMPTS
221
+ if attempts >= MAX_SLUG_GENERATION_ATTEMPTS
174
222
  slug_candidate = "#{base_slug}-#{Time.current.to_i}-#{SecureRandom.random_number(1000)}"
175
223
  end
176
224
 
@@ -218,15 +266,25 @@ module Slugifiable
218
266
  self.class.method_defined?(:slug) || self.class.private_method_defined?(:slug)
219
267
  end
220
268
 
269
+ # S1: after_create retry for nullable slug columns (post-INSERT collision)
270
+ # S2: Uses savepoint via with_slug_retry for PostgreSQL compatibility
221
271
  def set_slug
222
272
  return unless slug_persisted?
273
+ return unless slug.blank?
223
274
 
224
- self.slug = compute_slug if id_changed? || slug.blank?
225
- self.save
275
+ with_slug_retry do |_attempts|
276
+ self.slug = compute_slug
277
+ save!
278
+ end
226
279
  end
227
280
 
281
+ # S7: Non-bang save for after_find repair path to avoid read-time exceptions
282
+ # This path handles legacy records that may have nil slugs
228
283
  def update_slug_if_nil
229
- set_slug if slug_persisted? && self.slug.nil?
284
+ return unless slug_persisted? && slug.nil?
285
+
286
+ self.slug = compute_slug
287
+ save # Non-bang: read operations should not raise
230
288
  end
231
289
 
232
290
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Slugifiable
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: slugifiable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-01-16 00:00:00.000000000 Z
10
+ date: 2026-02-19 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails