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 +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +47 -3
- data/lib/slugifiable/model.rb +64 -6
- data/lib/slugifiable/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 230e7c9d0a24d77af781a78aed46daf46d404aa942572c4fab12f592fd24b3f7
|
|
4
|
+
data.tar.gz: ab8007017ef1f1ae5ecf563efb5a188e9a10150f5d861f9fc352d1b7f9023026
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
data/lib/slugifiable/model.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
|
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
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
data/lib/slugifiable/version.rb
CHANGED
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.
|
|
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-
|
|
10
|
+
date: 2026-02-19 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: rails
|