friendly_id 5.2.2 → 5.4.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.
Files changed (48) hide show
  1. checksums.yaml +5 -5
  2. checksums.yaml.gz.sig +0 -0
  3. data.tar.gz.sig +2 -0
  4. data/.github/stale.yml +17 -0
  5. data/.github/workflows/test.yml +60 -0
  6. data/Changelog.md +39 -1
  7. data/Gemfile +3 -0
  8. data/README.md +54 -164
  9. data/Rakefile +2 -2
  10. data/UPGRADING.md +115 -0
  11. data/certs/parndt.pem +25 -0
  12. data/friendly_id.gemspec +9 -5
  13. data/gemfiles/Gemfile.rails-5.0.rb +2 -2
  14. data/gemfiles/{Gemfile.rails-4.2.rb → Gemfile.rails-5.1.rb} +4 -5
  15. data/gemfiles/{Gemfile.rails-4.1.rb → Gemfile.rails-5.2.rb} +5 -7
  16. data/gemfiles/{Gemfile.rails-4.0.rb → Gemfile.rails-6.0.rb} +5 -8
  17. data/lib/friendly_id/base.rb +4 -8
  18. data/lib/friendly_id/candidates.rb +0 -2
  19. data/lib/friendly_id/configuration.rb +3 -2
  20. data/lib/friendly_id/finder_methods.rb +18 -7
  21. data/lib/friendly_id/finders.rb +1 -1
  22. data/lib/friendly_id/history.rb +21 -12
  23. data/lib/friendly_id/initializer.rb +11 -0
  24. data/lib/friendly_id/migration.rb +9 -3
  25. data/lib/friendly_id/object_utils.rb +9 -2
  26. data/lib/friendly_id/reserved.rb +1 -0
  27. data/lib/friendly_id/scoped.rb +9 -2
  28. data/lib/friendly_id/sequentially_slugged.rb +12 -2
  29. data/lib/friendly_id/slug.rb +4 -0
  30. data/lib/friendly_id/slug_generator.rb +6 -1
  31. data/lib/friendly_id/slugged.rb +3 -3
  32. data/lib/friendly_id/version.rb +1 -1
  33. data/test/databases.yml +6 -4
  34. data/test/finders_test.rb +24 -0
  35. data/test/helper.rb +13 -3
  36. data/test/history_test.rb +86 -7
  37. data/test/numeric_slug_test.rb +31 -0
  38. data/test/object_utils_test.rb +5 -3
  39. data/test/reserved_test.rb +10 -0
  40. data/test/schema.rb +19 -2
  41. data/test/scoped_test.rb +13 -0
  42. data/test/sequentially_slugged_test.rb +59 -0
  43. data/test/shared.rb +4 -4
  44. data/test/simple_i18n_test.rb +2 -2
  45. data/test/slugged_test.rb +168 -4
  46. metadata +48 -19
  47. metadata.gz.sig +0 -0
  48. data/.travis.yml +0 -40
@@ -18,6 +18,12 @@ FriendlyId.defaults do |config|
18
18
 
19
19
  config.reserved_words = %w(new edit index session login logout users admin
20
20
  stylesheets assets javascripts images)
21
+
22
+ # This adds an option to treat reserved words as conflicts rather than exceptions.
23
+ # When there is no good candidate, a UUID will be appended, matching the existing
24
+ # conflict behavior.
25
+
26
+ # config.treat_reserved_as_conflict = true
21
27
 
22
28
  # ## Friendly Finders
23
29
  #
@@ -76,7 +82,12 @@ FriendlyId.defaults do |config|
76
82
  # behavior by overriding the `should_generate_new_friendly_id?` method that
77
83
  # FriendlyId adds to your model. The change below makes FriendlyId 5.0 behave
78
84
  # more like 4.0.
85
+ # Note: Use(include) Slugged module in the config if using the anonymous module.
86
+ # If you have `friendly_id :name, use: slugged` in the model, Slugged module
87
+ # is included after the anonymous module defined in the initializer, so it
88
+ # overrides the `should_generate_new_friendly_id?` method from the anonymous module.
79
89
  #
90
+ # config.use :slugged
80
91
  # config.use Module.new {
81
92
  # def should_generate_new_friendly_id?
82
93
  # slug.blank? || <your_column_name_here>_changed?
@@ -1,4 +1,11 @@
1
- class CreateFriendlyIdSlugs < ActiveRecord::Migration
1
+ MIGRATION_CLASS =
2
+ if ActiveRecord::VERSION::MAJOR >= 5
3
+ ActiveRecord::Migration["#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}"]
4
+ else
5
+ ActiveRecord::Migration
6
+ end
7
+
8
+ class CreateFriendlyIdSlugs < MIGRATION_CLASS
2
9
  def change
3
10
  create_table :friendly_id_slugs do |t|
4
11
  t.string :slug, :null => false
@@ -7,9 +14,8 @@ class CreateFriendlyIdSlugs < ActiveRecord::Migration
7
14
  t.string :scope
8
15
  t.datetime :created_at
9
16
  end
10
- add_index :friendly_id_slugs, :sluggable_id
17
+ add_index :friendly_id_slugs, [:sluggable_type, :sluggable_id]
11
18
  add_index :friendly_id_slugs, [:slug, :sluggable_type], length: { slug: 140, sluggable_type: 50 }
12
19
  add_index :friendly_id_slugs, [:slug, :sluggable_type, :scope], length: { slug: 70, sluggable_type: 50, scope: 70 }, unique: true
13
- add_index :friendly_id_slugs, :sluggable_type
14
20
  end
15
21
  end
@@ -2,7 +2,6 @@ module FriendlyId
2
2
  # Instances of these classes will never be considered a friendly id.
3
3
  # @see FriendlyId::ObjectUtils#friendly_id
4
4
  UNFRIENDLY_CLASSES = [
5
- ActiveRecord::Base,
6
5
  Array,
7
6
  FalseClass,
8
7
  Hash,
@@ -59,6 +58,10 @@ module FriendlyId
59
58
  true
60
59
  end
61
60
  end
61
+
62
+ def self.mark_as_unfriendly(klass)
63
+ klass.send(:include, FriendlyId::UnfriendlyUtils)
64
+ end
62
65
  end
63
66
 
64
67
  Object.send :include, FriendlyId::ObjectUtils
@@ -66,4 +69,8 @@ Object.send :include, FriendlyId::ObjectUtils
66
69
  # Considered unfriendly if object is an instance of an unfriendly class or
67
70
  # one of its descendants.
68
71
 
69
- FriendlyId::UNFRIENDLY_CLASSES.each { |klass| klass.send(:include, FriendlyId::UnfriendlyUtils) }
72
+ FriendlyId::UNFRIENDLY_CLASSES.each { |klass| FriendlyId.mark_as_unfriendly(klass) }
73
+
74
+ ActiveSupport.on_load(:active_record) do
75
+ FriendlyId.mark_as_unfriendly(ActiveRecord::Base)
76
+ end
@@ -46,6 +46,7 @@ message to a different field. For example:
46
46
  # {FriendlyId::Configuration FriendlyId::Configuration}.
47
47
  module Configuration
48
48
  attr_accessor :reserved_words
49
+ attr_accessor :treat_reserved_as_conflict
49
50
  end
50
51
  end
51
52
  end
@@ -122,7 +122,10 @@ an example of one way to set this up:
122
122
  end
123
123
 
124
124
  def scope_for_slug_generator
125
- relation = self.class.unscoped.friendly
125
+ if friendly_id_config.uses?(:History)
126
+ return super
127
+ end
128
+ relation = self.class.base_class.unscoped.friendly
126
129
  friendly_id_config.scope_columns.each do |column|
127
130
  relation = relation.where(column => send(column))
128
131
  end
@@ -132,10 +135,14 @@ an example of one way to set this up:
132
135
  private :scope_for_slug_generator
133
136
 
134
137
  def slug_generator
135
- friendly_id_config.slug_generator_class.new(scope_for_slug_generator)
138
+ friendly_id_config.slug_generator_class.new(scope_for_slug_generator, friendly_id_config)
136
139
  end
137
140
  private :slug_generator
138
141
 
142
+ def should_generate_new_friendly_id?
143
+ (changed & friendly_id_config.scope_columns).any? || super
144
+ end
145
+
139
146
  # This module adds the `:scope` configuration option to
140
147
  # {FriendlyId::Configuration FriendlyId::Configuration}.
141
148
  module Configuration
@@ -11,7 +11,7 @@ module FriendlyId
11
11
  candidate,
12
12
  friendly_id_config.slug_column,
13
13
  friendly_id_config.sequence_separator,
14
- self.class.base_class).next_slug
14
+ slug_base_class).next_slug
15
15
  end
16
16
 
17
17
  class SequentialSlugCalculator
@@ -47,7 +47,7 @@ module FriendlyId
47
47
  def slug_conflicts
48
48
  scope.
49
49
  where(conflict_query, slug, sequential_slug_matcher).
50
- order(ordering_query).pluck(slug_column)
50
+ order(Arel.sql(ordering_query)).pluck(Arel.sql(slug_column))
51
51
  end
52
52
 
53
53
  def conflict_query
@@ -73,5 +73,15 @@ module FriendlyId
73
73
  "#{length_command}(#{slug_column}) ASC, #{slug_column} ASC"
74
74
  end
75
75
  end
76
+
77
+ private
78
+
79
+ def slug_base_class
80
+ if friendly_id_config.uses?(:history)
81
+ Slug
82
+ else
83
+ self.class.base_class
84
+ end
85
+ end
76
86
  end
77
87
  end
@@ -5,6 +5,10 @@ module FriendlyId
5
5
  class Slug < ActiveRecord::Base
6
6
  belongs_to :sluggable, :polymorphic => true
7
7
 
8
+ def sluggable
9
+ sluggable_type.constantize.unscoped { super }
10
+ end
11
+
8
12
  def to_param
9
13
  slug
10
14
  end
@@ -3,11 +3,16 @@ module FriendlyId
3
3
  # availability.
4
4
  class SlugGenerator
5
5
 
6
- def initialize(scope)
6
+ def initialize(scope, config)
7
7
  @scope = scope
8
+ @config = config
8
9
  end
9
10
 
10
11
  def available?(slug)
12
+ if @config.uses?(::FriendlyId::Reserved) && @config.reserved_words.present? && @config.treat_reserved_as_conflict
13
+ return false if @config.reserved_words.include?(slug)
14
+ end
15
+
11
16
  !@scope.exists_by_friendly_id?(slug)
12
17
  end
13
18
 
@@ -67,7 +67,7 @@ app's behavior and requirements.
67
67
  #### Formatting
68
68
 
69
69
  By default, FriendlyId uses Active Support's
70
- [paramaterize](http://api.rubyonrails.org/classes/ActiveSupport/Inflector.html#method-i-parameterize)
70
+ [parameterize](http://api.rubyonrails.org/classes/ActiveSupport/Inflector.html#method-i-parameterize)
71
71
  method to create slugs. This method will intelligently replace spaces with
72
72
  dashes, and Unicode Latin characters with ASCII approximations:
73
73
 
@@ -370,12 +370,12 @@ Github issue](https://github.com/norman/friendly_id/issues/185) for discussion.
370
370
  private :scope_for_slug_generator
371
371
 
372
372
  def slug_generator
373
- friendly_id_config.slug_generator_class.new(scope_for_slug_generator)
373
+ friendly_id_config.slug_generator_class.new(scope_for_slug_generator, friendly_id_config)
374
374
  end
375
375
  private :slug_generator
376
376
 
377
377
  def unset_slug_if_invalid
378
- if errors.present? && attribute_changed?(friendly_id_config.query_field.to_s)
378
+ if errors[friendly_id_config.query_field].present? && attribute_changed?(friendly_id_config.query_field.to_s)
379
379
  diff = changes[friendly_id_config.query_field]
380
380
  send "#{friendly_id_config.slug_column}=", diff.first
381
381
  end
@@ -1,3 +1,3 @@
1
1
  module FriendlyId
2
- VERSION = '5.2.2'.freeze
2
+ VERSION = '5.4.0'.freeze
3
3
  end
@@ -2,14 +2,16 @@ mysql:
2
2
  adapter: mysql2
3
3
  database: friendly_id_test
4
4
  username: root
5
- hostname: localhost
5
+ password: <%= ENV['MYSQL_PASSWORD'] %>
6
+ host: 127.0.0.1
7
+ port: 3306
6
8
  encoding: utf8
7
9
 
8
10
  postgres:
9
11
  adapter: postgresql
10
- host: localhost
11
- port: 5432
12
- username: postgres
12
+ host: <%= ENV.fetch('PGHOST') { 'localhost' } %>
13
+ port: <%= ENV.fetch('PGPORT') { '5432' } %>
14
+ username: <%= ENV.fetch('PGUSER') { 'postgres' } %>
13
15
  database: friendly_id_test
14
16
  encoding: utf8
15
17
 
@@ -26,4 +26,28 @@ class Finders < TestCaseClass
26
26
  assert model_class.existing.find(record.friendly_id)
27
27
  end
28
28
  end
29
+
30
+ test 'should find capitalized records with finders as class methods' do
31
+ with_instance_of(model_class) do |record|
32
+ assert model_class.find(record.friendly_id.capitalize)
33
+ end
34
+ end
35
+
36
+ test 'should find capitalized records with finders on relations' do
37
+ with_instance_of(model_class) do |record|
38
+ assert model_class.existing.find(record.friendly_id.capitalize)
39
+ end
40
+ end
41
+
42
+ test 'should find upcased records with finders as class methods' do
43
+ with_instance_of(model_class) do |record|
44
+ assert model_class.find(record.friendly_id.upcase)
45
+ end
46
+ end
47
+
48
+ test 'should find upcased records with finders on relations' do
49
+ with_instance_of(model_class) do |record|
50
+ assert model_class.existing.find(record.friendly_id.upcase)
51
+ end
52
+ end
29
53
  end
@@ -27,6 +27,7 @@ end
27
27
  require "mocha/setup"
28
28
  require "active_record"
29
29
  require 'active_support/core_ext/time/conversions'
30
+ require 'erb'
30
31
 
31
32
  I18n.enforce_available_locales = false
32
33
 
@@ -38,6 +39,10 @@ if ENV["LOG"]
38
39
  ActiveRecord::Base.logger = Logger.new($stdout)
39
40
  end
40
41
 
42
+ if ActiveSupport::VERSION::STRING >= '4.2'
43
+ ActiveSupport.test_order = :random
44
+ end
45
+
41
46
  module FriendlyId
42
47
  module Test
43
48
 
@@ -65,7 +70,6 @@ module FriendlyId
65
70
 
66
71
  def connect
67
72
  version = ActiveRecord::VERSION::STRING
68
- driver = FriendlyId::Test::Database.driver
69
73
  engine = RUBY_ENGINE rescue "ruby"
70
74
 
71
75
  ActiveRecord::Base.establish_connection config[driver]
@@ -82,11 +86,17 @@ module FriendlyId
82
86
  end
83
87
 
84
88
  def config
85
- @config ||= YAML::load(File.open(File.expand_path("../databases.yml", __FILE__)))
89
+ @config ||= YAML::load(
90
+ ERB.new(
91
+ File.read(File.expand_path("../databases.yml", __FILE__))
92
+ ).result
93
+ )
86
94
  end
87
95
 
88
96
  def driver
89
- (ENV["DB"] or "sqlite3").downcase
97
+ _driver = ENV.fetch('DB', 'sqlite3').downcase
98
+ _driver = "postgres" if %w(postgresql pg).include?(_driver)
99
+ _driver
90
100
  end
91
101
 
92
102
  def in_memory?
@@ -65,8 +65,7 @@ class HistoryTest < TestCaseClass
65
65
  test "should not be read only when found by slug" do
66
66
  with_instance_of(model_class) do |record|
67
67
  refute model_class.friendly.find(record.friendly_id).readonly?
68
- assert record.update_attribute :name, 'foo'
69
- assert record.update_attributes name: 'foo'
68
+ assert record.update name: 'foo'
70
69
  end
71
70
  end
72
71
 
@@ -93,6 +92,28 @@ class HistoryTest < TestCaseClass
93
92
  end
94
93
  end
95
94
 
95
+ test 'should maintain history even if current slug is not the most recent one' do
96
+ with_instance_of(model_class) do |record|
97
+ record.name = 'current'
98
+ assert record.save
99
+
100
+ # this feels like a hack. only thing i can get to work with the HistoryTestWithSti
101
+ # test cases. (Editorialist vs Journalist.)
102
+ sluggable_type = FriendlyId::Slug.first.sluggable_type
103
+ # create several slugs for record
104
+ # current slug does not have max id
105
+ FriendlyId::Slug.delete_all
106
+ FriendlyId::Slug.create(sluggable_type: sluggable_type, sluggable_id: record.id, slug: 'current')
107
+ FriendlyId::Slug.create(sluggable_type: sluggable_type, sluggable_id: record.id, slug: 'outdated')
108
+
109
+ record.reload
110
+ record.slug = nil
111
+ assert record.save
112
+
113
+ assert_equal 2, FriendlyId::Slug.count
114
+ end
115
+ end
116
+
96
117
  test "should not create new slugs that match old slugs" do
97
118
  transaction do
98
119
  first_record = model_class.create! :name => "foo"
@@ -109,10 +130,10 @@ class HistoryTest < TestCaseClass
109
130
  first_record = model_class.create! :name => "foo"
110
131
  second_record = model_class.create! :name => 'another'
111
132
 
112
- second_record.update_attributes :name => 'foo', :slug => nil
133
+ second_record.update :name => 'foo', :slug => nil
113
134
  assert_match(/foo-.*/, second_record.slug)
114
135
 
115
- first_record.update_attributes :name => 'another', :slug => nil
136
+ first_record.update :name => 'another', :slug => nil
116
137
  assert_match(/another-.*/, first_record.slug)
117
138
  end
118
139
  end
@@ -172,7 +193,7 @@ class HistoryTestWithAutomaticSlugRegeneration < HistoryTest
172
193
  end
173
194
  end
174
195
 
175
- class DependentDestroyTest < HistoryTest
196
+ class DependentDestroyTest < TestCaseClass
176
197
 
177
198
  include FriendlyId::Test
178
199
 
@@ -211,6 +232,37 @@ class DependentDestroyTest < HistoryTest
211
232
  end
212
233
  end
213
234
 
235
+ if ActiveRecord::VERSION::STRING >= '5.0'
236
+ class HistoryTestWithParanoidDeletes < HistoryTest
237
+ class ParanoidRecord < ActiveRecord::Base
238
+ extend FriendlyId
239
+ friendly_id :name, :use => :history, :dependent => false
240
+
241
+ default_scope { where(deleted_at: nil) }
242
+ end
243
+
244
+ def model_class
245
+ ParanoidRecord
246
+ end
247
+
248
+ test 'slug should have a sluggable even when soft deleted by a library' do
249
+ transaction do
250
+ assert FriendlyId::Slug.find_by_slug('paranoid').nil?
251
+ record = model_class.create(name: 'paranoid')
252
+ assert FriendlyId::Slug.find_by_slug('paranoid').present?
253
+
254
+ record.update deleted_at: Time.now
255
+
256
+ orphan_slug = FriendlyId::Slug.find_by_slug('paranoid')
257
+ assert orphan_slug.present?, 'Orphaned slug should exist'
258
+
259
+ assert orphan_slug.valid?, "Errors: #{orphan_slug.errors.full_messages}"
260
+ assert orphan_slug.sluggable.present?, 'Orphaned slug should still find corresponding paranoid sluggable'
261
+ end
262
+ end
263
+ end
264
+ end
265
+
214
266
  class HistoryTestWithSti < HistoryTest
215
267
  class Journalist < ActiveRecord::Base
216
268
  extend FriendlyId
@@ -248,7 +300,7 @@ class HistoryTestWithFriendlyFinders < HistoryTest
248
300
  begin
249
301
  assert model_class.find(old_friendly_id)
250
302
  assert model_class.exists?(old_friendly_id), "should exist? by old id for #{model_class.name}"
251
- rescue ActiveRecord::RecordNotFound => e
303
+ rescue ActiveRecord::RecordNotFound
252
304
  flunk "Could not find record by old id for #{model_class.name}"
253
305
  end
254
306
  end
@@ -346,6 +398,33 @@ class ScopedHistoryTest < TestCaseClass
346
398
  end
347
399
  end
348
400
 
401
+ test "should record history when scope changes" do
402
+ transaction do
403
+ city1 = City.create!
404
+ city2 = City.create!
405
+ with_instance_of(Restaurant) do |record|
406
+ record.name = "x"
407
+ record.slug = nil
408
+
409
+ record.city = city1
410
+ record.save!
411
+ assert_equal("city_id:#{city1.id}", record.slugs.reload.first.scope)
412
+ assert_equal("x", record.slugs.reload.first.slug)
413
+
414
+ record.city = city2
415
+ record.save!
416
+ assert_equal("city_id:#{city2.id}", record.slugs.reload.first.scope)
417
+
418
+ record.name = "y"
419
+ record.slug = nil
420
+ record.city = city1
421
+ record.save!
422
+ assert_equal("city_id:#{city1.id}", record.slugs.reload.first.scope)
423
+ assert_equal("y", record.slugs.reload.first.slug)
424
+ end
425
+ end
426
+ end
427
+
349
428
  test "should allow equal slugs in different scopes" do
350
429
  transaction do
351
430
  city = City.create!
@@ -356,4 +435,4 @@ class ScopedHistoryTest < TestCaseClass
356
435
  assert_equal record.slug, second_record.slug
357
436
  end
358
437
  end
359
- end
438
+ end