slugifiable 0.1.1 → 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/.simplecov +35 -0
- data/AGENTS.md +5 -0
- data/Appraisals +20 -0
- data/CHANGELOG.md +23 -1
- data/CLAUDE.md +5 -0
- data/README.md +54 -7
- data/gemfiles/rails_7.2.gemfile +17 -0
- data/gemfiles/rails_8.0.gemfile +17 -0
- data/gemfiles/rails_8.1.gemfile +17 -0
- data/lib/slugifiable/model.rb +95 -15
- data/lib/slugifiable/version.rb +1 -1
- data/lib/slugifiable.rb +2 -0
- metadata +9 -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/.simplecov
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SimpleCov configuration file (auto-loaded before test suite)
|
|
4
|
+
# This keeps test_helper.rb clean and follows best practices
|
|
5
|
+
|
|
6
|
+
SimpleCov.start do
|
|
7
|
+
# Use SimpleFormatter for terminal-only output (no HTML generation)
|
|
8
|
+
formatter SimpleCov::Formatter::SimpleFormatter
|
|
9
|
+
|
|
10
|
+
# Track coverage for the lib directory (gem source code)
|
|
11
|
+
add_filter "/test/"
|
|
12
|
+
|
|
13
|
+
# Track the lib and app directories
|
|
14
|
+
track_files "{lib,app}/**/*.rb"
|
|
15
|
+
|
|
16
|
+
# Enable branch coverage for more detailed metrics
|
|
17
|
+
enable_coverage :branch
|
|
18
|
+
|
|
19
|
+
# Set minimum coverage threshold to prevent coverage regression
|
|
20
|
+
minimum_coverage line: 90, branch: 90
|
|
21
|
+
|
|
22
|
+
# Disambiguate parallel test runs
|
|
23
|
+
command_name "Job #{ENV['TEST_ENV_NUMBER']}" if ENV['TEST_ENV_NUMBER']
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Print coverage summary to terminal after tests complete
|
|
27
|
+
SimpleCov.at_exit do
|
|
28
|
+
SimpleCov.result.format!
|
|
29
|
+
puts "\n" + "=" * 60
|
|
30
|
+
puts "COVERAGE SUMMARY"
|
|
31
|
+
puts "=" * 60
|
|
32
|
+
puts "Line Coverage: #{SimpleCov.result.covered_percent.round(2)}%"
|
|
33
|
+
puts "Branch Coverage: #{SimpleCov.result.coverage_statistics[:branch]&.percent&.round(2) || 'N/A'}%"
|
|
34
|
+
puts "=" * 60
|
|
35
|
+
end
|
data/AGENTS.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to AI Agents (like OpenAI's Codex, Cursor Agent, Claude Code, etc) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
Please go ahead and read the full context for this project at `.cursor/rules/0-overview.mdc` and `.cursor/rules/1-quality.mdc` now. Also read the README for a good overview of the project.
|
data/Appraisals
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Note: Rails < 7.2 is not compatible with Ruby 3.4
|
|
4
|
+
# (Logger became a bundled gem in Ruby 3.4, and only Rails 7.2+ handles this)
|
|
5
|
+
# See: https://stdgems.org/logger/
|
|
6
|
+
|
|
7
|
+
# Test against Rails 7.2 (minimum version compatible with Ruby 3.4)
|
|
8
|
+
appraise "rails-7.2" do
|
|
9
|
+
gem "rails", "~> 7.2.0"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Test against Rails 8.0
|
|
13
|
+
appraise "rails-8.0" do
|
|
14
|
+
gem "rails", "~> 8.0.0"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Test against Rails 8.1 (latest)
|
|
18
|
+
appraise "rails-8.1" do
|
|
19
|
+
gem "rails", "~> 8.1.2"
|
|
20
|
+
end
|
data/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,26 @@
|
|
|
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
|
+
|
|
9
|
+
## [0.2.0] - 2026-01-16
|
|
10
|
+
|
|
11
|
+
- Added a full Minitest test suite
|
|
12
|
+
- Fixed `respond_to?(:slug)` returning false for models using method_missing by implementing `respond_to_missing?`
|
|
13
|
+
- Fixed collision resolution generating identical suffixes by switching from id-based deterministic suffixes to SecureRandom-based random suffixes
|
|
14
|
+
- Fixed length parameter edge cases (zero, negative, very large values) by adding length validation and clamping via `normalize_length` method
|
|
15
|
+
- Fixed timestamp fallback to include random suffix for additional uniqueness guarantee
|
|
16
|
+
- Added length validation constants (`MAX_HEX_STRING_LENGTH`, `MAX_NUMBER_LENGTH`) to prevent invalid length values
|
|
17
|
+
|
|
18
|
+
## [0.1.1] - 2024-03-21
|
|
19
|
+
|
|
20
|
+
- Slugs can be now generated based off methods that return a string, not just based off attributes
|
|
21
|
+
- Enhanced collision resolution strategy so that it doesn't get stuck in infinite loops
|
|
22
|
+
- Added comprehensive test suite
|
|
23
|
+
|
|
1
24
|
## [0.1.0] - 2024-10-30
|
|
2
25
|
|
|
3
26
|
- Initial release
|
|
4
|
-
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
Please go ahead and read the full context for this project at `.cursor/rules/0-overview.mdc` and `.cursor/rules/1-quality.mdc` now. Also read the README for a good overview of the project.
|
data/README.md
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
# 🐌 `slugifiable` -
|
|
1
|
+
# 🐌 `slugifiable` - Generate SEO-optimized URL slugs
|
|
2
2
|
|
|
3
|
-
[](https://badge.fury.io/rb/slugifiable)
|
|
3
|
+
[](https://badge.fury.io/rb/slugifiable) [](https://github.com/rameerez/slugifiable/actions)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
> [!TIP]
|
|
6
|
+
> **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=slugifiable)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks. Go [check it out](https://railsfast.com/?ref=slugifiable)!
|
|
7
|
+
|
|
8
|
+
Ruby gem to automatically generate unique slugs for your Rails' model records, so you can expose SEO-friendly URLs.
|
|
6
9
|
|
|
7
10
|
Example:
|
|
8
11
|
```
|
|
@@ -77,9 +80,53 @@ Product.first.slug
|
|
|
77
80
|
|
|
78
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.
|
|
79
82
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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.
|
|
83
130
|
|
|
84
131
|
If you're generating slugs based off the model `id`, you can also set a desired length:
|
|
85
132
|
```ruby
|
|
@@ -183,7 +230,7 @@ end
|
|
|
183
230
|
This will generate slugs like:
|
|
184
231
|
```ruby
|
|
185
232
|
Event.first.slug
|
|
186
|
-
=> "my-awesome-event-new-york
|
|
233
|
+
=> "my-awesome-event-new-york" # Automatically parameterized
|
|
187
234
|
```
|
|
188
235
|
|
|
189
236
|
There may be collisions if two records share the same name – but slugs should be unique! To resolve this, when this happens, `slugifiable` will append a unique string at the end to make the slug unique:
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "rake", "~> 13.0"
|
|
6
|
+
gem "rails", "~> 7.2.0"
|
|
7
|
+
|
|
8
|
+
group :development, :test do
|
|
9
|
+
gem "appraisal"
|
|
10
|
+
gem "minitest", "~> 6.0"
|
|
11
|
+
gem "minitest-mock"
|
|
12
|
+
gem "rack-test"
|
|
13
|
+
gem "simplecov", require: false
|
|
14
|
+
gem "sqlite3", ">= 2.1"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "rake", "~> 13.0"
|
|
6
|
+
gem "rails", "~> 8.0.0"
|
|
7
|
+
|
|
8
|
+
group :development, :test do
|
|
9
|
+
gem "appraisal"
|
|
10
|
+
gem "minitest", "~> 6.0"
|
|
11
|
+
gem "minitest-mock"
|
|
12
|
+
gem "rack-test"
|
|
13
|
+
gem "simplecov", require: false
|
|
14
|
+
gem "sqlite3", ">= 2.1"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "rake", "~> 13.0"
|
|
6
|
+
gem "rails", "~> 8.1.2"
|
|
7
|
+
|
|
8
|
+
group :development, :test do
|
|
9
|
+
gem "appraisal"
|
|
10
|
+
gem "minitest", "~> 6.0"
|
|
11
|
+
gem "minitest-mock"
|
|
12
|
+
gem "rack-test"
|
|
13
|
+
gem "simplecov", require: false
|
|
14
|
+
gem "sqlite3", ">= 2.1"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
gemspec path: "../"
|
data/lib/slugifiable/model.rb
CHANGED
|
@@ -28,11 +28,17 @@ module Slugifiable
|
|
|
28
28
|
DEFAULT_SLUG_STRING_LENGTH = 11
|
|
29
29
|
DEFAULT_SLUG_NUMBER_LENGTH = 6
|
|
30
30
|
|
|
31
|
-
#
|
|
32
|
-
|
|
31
|
+
# SHA256 produces 64 hex characters
|
|
32
|
+
MAX_HEX_STRING_LENGTH = 64
|
|
33
|
+
# 10^18 fits safely in a 64-bit integer
|
|
34
|
+
MAX_NUMBER_LENGTH = 18
|
|
35
|
+
|
|
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).
|
|
33
38
|
MAX_SLUG_GENERATION_ATTEMPTS = 10
|
|
34
39
|
|
|
35
40
|
included do
|
|
41
|
+
around_create :retry_create_on_slug_unique_violation
|
|
36
42
|
after_create :set_slug
|
|
37
43
|
after_find :update_slug_if_nil
|
|
38
44
|
validates :slug, uniqueness: true
|
|
@@ -51,13 +57,17 @@ module Slugifiable
|
|
|
51
57
|
end
|
|
52
58
|
|
|
53
59
|
def method_missing(missing_method, *args, &block)
|
|
54
|
-
if missing_method.to_s == "slug" && !
|
|
60
|
+
if missing_method.to_s == "slug" && !has_slug_method?
|
|
55
61
|
compute_slug
|
|
56
62
|
else
|
|
57
63
|
super
|
|
58
64
|
end
|
|
59
65
|
end
|
|
60
66
|
|
|
67
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
68
|
+
method_name.to_s == "slug" && !has_slug_method? || super
|
|
69
|
+
end
|
|
70
|
+
|
|
61
71
|
def compute_slug
|
|
62
72
|
strategy, options = determine_slug_generation_method
|
|
63
73
|
|
|
@@ -71,12 +81,12 @@ module Slugifiable
|
|
|
71
81
|
end
|
|
72
82
|
|
|
73
83
|
def compute_slug_as_string(length = DEFAULT_SLUG_STRING_LENGTH)
|
|
74
|
-
length
|
|
84
|
+
length = normalize_length(length, DEFAULT_SLUG_STRING_LENGTH, MAX_HEX_STRING_LENGTH)
|
|
75
85
|
(Digest::SHA2.hexdigest self.id.to_s).first(length)
|
|
76
86
|
end
|
|
77
87
|
|
|
78
88
|
def compute_slug_as_number(length = DEFAULT_SLUG_NUMBER_LENGTH)
|
|
79
|
-
length
|
|
89
|
+
length = normalize_length(length, DEFAULT_SLUG_NUMBER_LENGTH, MAX_NUMBER_LENGTH)
|
|
80
90
|
generate_random_number_based_on_id_hex(length)
|
|
81
91
|
end
|
|
82
92
|
|
|
@@ -129,8 +139,61 @@ module Slugifiable
|
|
|
129
139
|
|
|
130
140
|
private
|
|
131
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
|
+
|
|
189
|
+
def normalize_length(length, default, max)
|
|
190
|
+
length = length.to_i
|
|
191
|
+
return default if length <= 0
|
|
192
|
+
[length, max].min
|
|
193
|
+
end
|
|
194
|
+
|
|
132
195
|
def generate_random_number_based_on_id_hex(length = DEFAULT_SLUG_NUMBER_LENGTH)
|
|
133
|
-
length
|
|
196
|
+
length = normalize_length(length, DEFAULT_SLUG_NUMBER_LENGTH, MAX_NUMBER_LENGTH)
|
|
134
197
|
((Digest::SHA2.hexdigest(id.to_s)).hex % (10 ** length))
|
|
135
198
|
end
|
|
136
199
|
|
|
@@ -147,14 +210,16 @@ module Slugifiable
|
|
|
147
210
|
|
|
148
211
|
while self.class.exists?(slug: slug_candidate) && attempts < MAX_SLUG_GENERATION_ATTEMPTS
|
|
149
212
|
attempts += 1
|
|
150
|
-
|
|
213
|
+
# Use SecureRandom for truly random suffixes during collision resolution
|
|
214
|
+
# This ensures each attempt tries a different suffix
|
|
215
|
+
random_suffix = SecureRandom.random_number(10 ** DEFAULT_SLUG_NUMBER_LENGTH)
|
|
151
216
|
slug_candidate = "#{base_slug}-#{random_suffix}"
|
|
152
217
|
end
|
|
153
218
|
|
|
154
219
|
# If we couldn't find a unique slug after MAX_SLUG_GENERATION_ATTEMPTS,
|
|
155
|
-
# append timestamp to ensure uniqueness
|
|
156
|
-
if attempts
|
|
157
|
-
slug_candidate = "#{base_slug}-#{Time.current.to_i}"
|
|
220
|
+
# append timestamp + random to ensure uniqueness
|
|
221
|
+
if attempts >= MAX_SLUG_GENERATION_ATTEMPTS
|
|
222
|
+
slug_candidate = "#{base_slug}-#{Time.current.to_i}-#{SecureRandom.random_number(1000)}"
|
|
158
223
|
end
|
|
159
224
|
|
|
160
225
|
slug_candidate
|
|
@@ -185,7 +250,7 @@ module Slugifiable
|
|
|
185
250
|
return [DEFAULT_SLUG_GENERATION_STRATEGY, options]
|
|
186
251
|
end
|
|
187
252
|
elsif strategy.key?(:attribute)
|
|
188
|
-
return [:compute_slug_based_on_attribute, strategy[:attribute]
|
|
253
|
+
return [:compute_slug_based_on_attribute, strategy[:attribute]]
|
|
189
254
|
end
|
|
190
255
|
end
|
|
191
256
|
|
|
@@ -193,18 +258,33 @@ module Slugifiable
|
|
|
193
258
|
end
|
|
194
259
|
|
|
195
260
|
def slug_persisted?
|
|
196
|
-
|
|
261
|
+
has_slug_method? && self.attributes.include?("slug")
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def has_slug_method?
|
|
265
|
+
# Check if slug method exists from ActiveRecord (not from method_missing)
|
|
266
|
+
self.class.method_defined?(:slug) || self.class.private_method_defined?(:slug)
|
|
197
267
|
end
|
|
198
268
|
|
|
269
|
+
# S1: after_create retry for nullable slug columns (post-INSERT collision)
|
|
270
|
+
# S2: Uses savepoint via with_slug_retry for PostgreSQL compatibility
|
|
199
271
|
def set_slug
|
|
200
272
|
return unless slug_persisted?
|
|
273
|
+
return unless slug.blank?
|
|
201
274
|
|
|
202
|
-
|
|
203
|
-
|
|
275
|
+
with_slug_retry do |_attempts|
|
|
276
|
+
self.slug = compute_slug
|
|
277
|
+
save!
|
|
278
|
+
end
|
|
204
279
|
end
|
|
205
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
|
|
206
283
|
def update_slug_if_nil
|
|
207
|
-
|
|
284
|
+
return unless slug_persisted? && slug.nil?
|
|
285
|
+
|
|
286
|
+
self.slug = compute_slug
|
|
287
|
+
save # Non-bang: read operations should not raise
|
|
208
288
|
end
|
|
209
289
|
|
|
210
290
|
end
|
data/lib/slugifiable/version.rb
CHANGED
data/lib/slugifiable.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:
|
|
10
|
+
date: 2026-02-19 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: rails
|
|
@@ -32,10 +32,17 @@ executables: []
|
|
|
32
32
|
extensions: []
|
|
33
33
|
extra_rdoc_files: []
|
|
34
34
|
files:
|
|
35
|
+
- ".simplecov"
|
|
36
|
+
- AGENTS.md
|
|
37
|
+
- Appraisals
|
|
35
38
|
- CHANGELOG.md
|
|
39
|
+
- CLAUDE.md
|
|
36
40
|
- LICENSE.txt
|
|
37
41
|
- README.md
|
|
38
42
|
- Rakefile
|
|
43
|
+
- gemfiles/rails_7.2.gemfile
|
|
44
|
+
- gemfiles/rails_8.0.gemfile
|
|
45
|
+
- gemfiles/rails_8.1.gemfile
|
|
39
46
|
- lib/slugifiable.rb
|
|
40
47
|
- lib/slugifiable/model.rb
|
|
41
48
|
- lib/slugifiable/version.rb
|