slugifiable 0.1.0 → 0.1.1

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: d06633ba0907f8ee400648856e3d8dbc95fc647be40d60780ae01ebbb6eeb7d3
4
- data.tar.gz: d40cae2d3fc081af04bd6fac08ea8640d8174c1e59b07652c92bab72eb8a18ca
3
+ metadata.gz: e9f707db7b8e857da8289a7e17754261aa3dd4b06b517673b983936cc449e530
4
+ data.tar.gz: 36144ba06043096a3ead2aa5e3728a3c6c0f54bf9be15c27d085e944d47a1b76
5
5
  SHA512:
6
- metadata.gz: 39dfad3e03b4cfef9946a929c5fe21585f2aab1ef1922d2f13ad8d10d6d4b732e3eaf6d4dcff545d9664d0395909fa6fa5e40a92801627625ef4034467b93bd9
7
- data.tar.gz: 8b726b22d56141b633f415460f8a8f8e7042379bc06aebb383f15360914b294bf29a0fffe2aa5e97e25577dab3e962d2b221bf7f18ab0d8822437f77ca97bfef
6
+ metadata.gz: 848886284f316ccb3086afcb268ddf09bf136c040a055c353fa4492021d578e439367ce137d471334a339fb43ff2997271267b2c0668d25f7654803cd4e73cad
7
+ data.tar.gz: 984f18e9268a0fff5b64f3e8d28e654c2586fa99c02f2199134bbac798cb311e68e5714b17c22ef6583eae75f51cce22b209ac08791d973dbe7f71a2e8830645
data/README.md CHANGED
@@ -77,6 +77,10 @@ Product.first.slug
77
77
 
78
78
  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
79
 
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
+
80
84
  If you're generating slugs based off the model `id`, you can also set a desired length:
81
85
  ```ruby
82
86
  class Product < ApplicationRecord
@@ -157,12 +161,56 @@ Product.first.slug
157
161
  => "big-red-backpack"
158
162
  ```
159
163
 
164
+ You can also use instance methods to generate more complex slugs. This is useful when you need to combine multiple attributes:
165
+ ```ruby
166
+ class Event < ApplicationRecord
167
+ include Slugifiable::Model
168
+ belongs_to :location
169
+
170
+ generate_slug_based_on :title_with_location
171
+
172
+ # The method can return any string - slugifiable will handle the parameterization
173
+ def title_with_location
174
+ if location.present?
175
+ "#{title} #{location.city} #{location.region}" # Returns raw string, slugifiable parameterizes it
176
+ else
177
+ title
178
+ end
179
+ end
180
+ end
181
+ ```
182
+
183
+ This will generate slugs like:
184
+ ```ruby
185
+ Event.first.slug
186
+ => "my-awesome-event-new-york-new-york" # Automatically parameterized
187
+ ```
188
+
160
189
  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:
161
190
  ```ruby
162
191
  Product.first.slug
163
192
  => "big-red-backpack-321678"
164
193
  ```
165
194
 
195
+ ## Testing
196
+
197
+ The gem includes a comprehensive test suite that covers:
198
+
199
+ - Default slug generation behavior
200
+ - Attribute-based slug generation
201
+ - Method-based slug generation (including private/protected methods)
202
+ - Collision resolution and uniqueness handling
203
+ - Special cases and edge conditions
204
+
205
+ To run the tests:
206
+
207
+ ```bash
208
+ bundle install
209
+ bundle exec rake test
210
+ ```
211
+
212
+ The test suite uses SQLite3 in-memory database and requires no additional setup.
213
+
166
214
  ## Development
167
215
 
168
216
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
data/Rakefile CHANGED
@@ -1,4 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bundler/gem_tasks"
4
- task default: %i[]
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ end
11
+
12
+ task default: :test
@@ -28,6 +28,10 @@ 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
33
+ MAX_SLUG_GENERATION_ATTEMPTS = 10
34
+
31
35
  included do
32
36
  after_create :set_slug
33
37
  after_find :update_slug_if_nil
@@ -36,11 +40,14 @@ module Slugifiable
36
40
 
37
41
  class_methods do
38
42
  def generate_slug_based_on(strategy, options = {})
43
+ # Remove previous definition if it exists to avoid warnings
44
+ remove_method(:generate_slug_based_on) if method_defined?(:generate_slug_based_on)
45
+
46
+ # Define the method that returns the strategy
39
47
  define_method(:generate_slug_based_on) do
40
48
  [strategy, options]
41
49
  end
42
50
  end
43
-
44
51
  end
45
52
 
46
53
  def method_missing(missing_method, *args, &block)
@@ -54,7 +61,7 @@ module Slugifiable
54
61
  def compute_slug
55
62
  strategy, options = determine_slug_generation_method
56
63
 
57
- length = options[:length] if options.is_a?(Hash) || nil
64
+ length = options[:length] if options.is_a?(Hash)
58
65
 
59
66
  if strategy == :compute_slug_based_on_attribute
60
67
  self.send(strategy, options)
@@ -74,11 +81,48 @@ module Slugifiable
74
81
  end
75
82
 
76
83
  def compute_slug_based_on_attribute(attribute_name)
77
- return compute_slug_as_string unless self.attributes.include?(attribute_name.to_s)
78
-
79
- base_slug = self.send(attribute_name)&.to_s&.strip&.parameterize
80
- base_slug = base_slug.presence || generate_random_number_based_on_id_hex
81
-
84
+ # This method generates a slug from either:
85
+ # 1. A database column (e.g. generate_slug_based_on :title)
86
+ # 2. An instance method (e.g. generate_slug_based_on :title_with_location)
87
+ #
88
+ # Priority:
89
+ # - Database columns take precedence over methods with the same name
90
+ # - Falls back to methods if no matching column exists
91
+ # - Falls back to ID-based slug if neither exists
92
+ #
93
+ # Flow:
94
+ # 1. Check if source exists (DB column first, then method)
95
+ # 2. Get raw value
96
+ # 3. Parameterize (convert "My Title" -> "my-title")
97
+ # 4. Ensure uniqueness
98
+ # 5. Fallback to random number if anything fails
99
+
100
+ # First check if we can get a value from the database
101
+ has_attribute = self.attributes.include?(attribute_name.to_s)
102
+
103
+ # Only check for methods if no DB attribute exists
104
+ # We check all method types to be thorough
105
+ responds_to_method = !has_attribute && (
106
+ self.class.method_defined?(attribute_name) ||
107
+ self.class.private_method_defined?(attribute_name) ||
108
+ self.class.protected_method_defined?(attribute_name)
109
+ )
110
+
111
+ # If we can't get a value from either source, fallback to using the record's ID
112
+ return compute_slug_as_string unless has_attribute || responds_to_method
113
+
114
+ # Get and clean the raw value (e.g. " My Title " -> "My Title")
115
+ # Works for both DB attributes and methods thanks to Ruby's send
116
+ raw_value = self.send(attribute_name)
117
+ return generate_random_number_based_on_id_hex if raw_value.nil?
118
+
119
+ # Convert to URL-friendly format
120
+ # e.g. "My Title" -> "my-title"
121
+ base_slug = raw_value.to_s.strip.parameterize
122
+ return generate_random_number_based_on_id_hex if base_slug.blank?
123
+
124
+ # Handle duplicate slugs by adding a random suffix if needed
125
+ # e.g. "my-title" -> "my-title-123456"
82
126
  unique_slug = generate_unique_slug(base_slug)
83
127
  unique_slug.presence || generate_random_number_based_on_id_hex
84
128
  end
@@ -96,13 +140,21 @@ module Slugifiable
96
140
  return slug_candidate unless slug_persisted?
97
141
 
98
142
  # Collision resolution logic:
143
+ # Try up to MAX_SLUG_GENERATION_ATTEMPTS times with random suffixes
144
+ # This prevents infinite loops while still giving us good odds
145
+ # of finding a unique slug
146
+ attempts = 0
147
+
148
+ while self.class.exists?(slug: slug_candidate) && attempts < MAX_SLUG_GENERATION_ATTEMPTS
149
+ attempts += 1
150
+ random_suffix = compute_slug_as_number
151
+ slug_candidate = "#{base_slug}-#{random_suffix}"
152
+ end
99
153
 
100
- count = 0
101
-
102
- while self.class.exists?(slug: slug_candidate)
103
- count += 1
104
- # slug_candidate = "#{base_slug}-#{count}"
105
- slug_candidate = "#{base_slug}-#{compute_slug_as_number}"
154
+ # 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}"
106
158
  end
107
159
 
108
160
  slug_candidate
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Slugifiable
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: slugifiable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2024-10-30 00:00:00.000000000 Z
10
+ date: 2025-02-23 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: rails
@@ -49,7 +48,6 @@ metadata:
49
48
  homepage_uri: https://github.com/rameerez/slugifiable
50
49
  source_code_uri: https://github.com/rameerez/slugifiable
51
50
  changelog_uri: https://github.com/rameerez/slugifiable/blob/main/CHANGELOG.md
52
- post_install_message:
53
51
  rdoc_options: []
54
52
  require_paths:
55
53
  - lib
@@ -64,8 +62,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
64
62
  - !ruby/object:Gem::Version
65
63
  version: '0'
66
64
  requirements: []
67
- rubygems_version: 3.5.16
68
- signing_key:
65
+ rubygems_version: 3.6.2
69
66
  specification_version: 4
70
67
  summary: Easily generate unique and SEO optimized slugs for your Rails ActiveRecord
71
68
  model records, based on any attribute