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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e9f707db7b8e857da8289a7e17754261aa3dd4b06b517673b983936cc449e530
4
- data.tar.gz: 36144ba06043096a3ead2aa5e3728a3c6c0f54bf9be15c27d085e944d47a1b76
3
+ metadata.gz: 230e7c9d0a24d77af781a78aed46daf46d404aa942572c4fab12f592fd24b3f7
4
+ data.tar.gz: ab8007017ef1f1ae5ecf563efb5a188e9a10150f5d861f9fc352d1b7f9023026
5
5
  SHA512:
6
- metadata.gz: 848886284f316ccb3086afcb268ddf09bf136c040a055c353fa4492021d578e439367ce137d471334a339fb43ff2997271267b2c0668d25f7654803cd4e73cad
7
- data.tar.gz: 984f18e9268a0fff5b64f3e8d28e654c2586fa99c02f2199134bbac798cb311e68e5714b17c22ef6583eae75f51cce22b209ac08791d973dbe7f71a2e8830645
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` - Rails gem to generate SEO-friendly slugs
1
+ # 🐌 `slugifiable` - Generate SEO-optimized URL slugs
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/slugifiable.svg)](https://badge.fury.io/rb/slugifiable)
3
+ [![Gem Version](https://badge.fury.io/rb/slugifiable.svg)](https://badge.fury.io/rb/slugifiable) [![Build Status](https://github.com/rameerez/slugifiable/workflows/Tests/badge.svg)](https://github.com/rameerez/slugifiable/actions)
4
4
 
5
- Automatically generates unique slugs for your Rails' model records, so you can expose SEO-friendly URLs.
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
- > [!IMPORTANT]
81
- > 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)`
82
- > 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.
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-new-york" # Automatically parameterized
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: "../"
@@ -28,11 +28,17 @@ module Slugifiable
28
28
  DEFAULT_SLUG_STRING_LENGTH = 11
29
29
  DEFAULT_SLUG_NUMBER_LENGTH = 6
30
30
 
31
- # Maximum number of attempts to generate a unique slug
32
- # before falling back to timestamp-based suffix
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" && !self.methods.include?(: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 ||= DEFAULT_SLUG_STRING_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 ||= DEFAULT_SLUG_NUMBER_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 ||= DEFAULT_SLUG_NUMBER_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
- random_suffix = compute_slug_as_number
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 == MAX_SLUG_GENERATION_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], options]
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
- self.methods.include?(:slug) && self.attributes.include?("slug")
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
- self.slug = compute_slug if id_changed? || slug.blank?
203
- self.save
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
- 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
208
288
  end
209
289
 
210
290
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Slugifiable
4
- VERSION = "0.1.1"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/slugifiable.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  require_relative "slugifiable/version"
4
6
  require_relative "slugifiable/model"
5
7
 
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.1.1
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: 2025-02-23 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
@@ -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