friendly_id 5.4.0 → 5.5.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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/.github/FUNDING.yml +1 -0
  4. data/.github/dependabot.yml +6 -0
  5. data/.github/stale.yml +1 -1
  6. data/.github/workflows/test.yml +38 -36
  7. data/.yardopts +2 -0
  8. data/Changelog.md +19 -0
  9. data/Gemfile +9 -13
  10. data/README.md +31 -8
  11. data/Rakefile +24 -27
  12. data/bench.rb +30 -27
  13. data/certs/parndt.pem +25 -23
  14. data/friendly_id.gemspec +26 -29
  15. data/gemfiles/Gemfile.rails-5.2.rb +11 -16
  16. data/gemfiles/Gemfile.rails-6.0.rb +11 -16
  17. data/gemfiles/Gemfile.rails-6.1.rb +22 -0
  18. data/gemfiles/Gemfile.rails-7.0.rb +22 -0
  19. data/guide.rb +13 -6
  20. data/lib/friendly_id/base.rb +59 -60
  21. data/lib/friendly_id/candidates.rb +9 -11
  22. data/lib/friendly_id/configuration.rb +6 -7
  23. data/lib/friendly_id/finder_methods.rb +63 -15
  24. data/lib/friendly_id/finders.rb +66 -66
  25. data/lib/friendly_id/history.rb +62 -63
  26. data/lib/friendly_id/initializer.rb +4 -4
  27. data/lib/friendly_id/migration.rb +6 -6
  28. data/lib/friendly_id/object_utils.rb +2 -2
  29. data/lib/friendly_id/reserved.rb +30 -32
  30. data/lib/friendly_id/scoped.rb +99 -102
  31. data/lib/friendly_id/sequentially_slugged/calculator.rb +69 -0
  32. data/lib/friendly_id/sequentially_slugged.rb +17 -64
  33. data/lib/friendly_id/simple_i18n.rb +78 -69
  34. data/lib/friendly_id/slug.rb +1 -2
  35. data/lib/friendly_id/slug_generator.rb +1 -3
  36. data/lib/friendly_id/slugged.rb +238 -239
  37. data/lib/friendly_id/version.rb +1 -1
  38. data/lib/friendly_id.rb +47 -49
  39. data/lib/generators/friendly_id_generator.rb +9 -9
  40. data/test/base_test.rb +10 -13
  41. data/test/benchmarks/finders.rb +28 -26
  42. data/test/benchmarks/object_utils.rb +13 -13
  43. data/test/candidates_test.rb +17 -18
  44. data/test/configuration_test.rb +7 -11
  45. data/test/core_test.rb +1 -2
  46. data/test/databases.yml +4 -3
  47. data/test/finders_test.rb +36 -13
  48. data/test/generator_test.rb +16 -26
  49. data/test/helper.rb +31 -24
  50. data/test/history_test.rb +70 -74
  51. data/test/numeric_slug_test.rb +4 -4
  52. data/test/object_utils_test.rb +0 -2
  53. data/test/reserved_test.rb +9 -11
  54. data/test/schema.rb +5 -4
  55. data/test/scoped_test.rb +18 -20
  56. data/test/sequentially_slugged_test.rb +65 -50
  57. data/test/shared.rb +15 -16
  58. data/test/simple_i18n_test.rb +22 -12
  59. data/test/slugged_test.rb +125 -113
  60. data/test/sti_test.rb +19 -21
  61. data.tar.gz.sig +0 -0
  62. metadata +38 -34
  63. metadata.gz.sig +0 -0
  64. data/gemfiles/Gemfile.rails-5.0.rb +0 -28
  65. data/gemfiles/Gemfile.rails-5.1.rb +0 -27
@@ -1,108 +1,106 @@
1
1
  require "friendly_id/slugged"
2
2
 
3
3
  module FriendlyId
4
-
5
- =begin
6
-
7
- ## Unique Slugs by Scope
8
-
9
- The {FriendlyId::Scoped} module allows FriendlyId to generate unique slugs
10
- within a scope.
11
-
12
- This allows, for example, two restaurants in different cities to have the slug
13
- `joes-diner`:
14
-
15
- class Restaurant < ActiveRecord::Base
16
- extend FriendlyId
17
- belongs_to :city
18
- friendly_id :name, :use => :scoped, :scope => :city
19
- end
20
-
21
- class City < ActiveRecord::Base
22
- extend FriendlyId
23
- has_many :restaurants
24
- friendly_id :name, :use => :slugged
25
- end
26
-
27
- City.friendly.find("seattle").restaurants.friendly.find("joes-diner")
28
- City.friendly.find("chicago").restaurants.friendly.find("joes-diner")
29
-
30
- Without :scoped in this case, one of the restaurants would have the slug
31
- `joes-diner` and the other would have `joes-diner-f9f3789a-daec-4156-af1d-fab81aa16ee5`.
32
-
33
- The value for the `:scope` option can be the name of a `belongs_to` relation, or
34
- a column.
35
-
36
- Additionally, the `:scope` option can receive an array of scope values:
37
-
38
- class Cuisine < ActiveRecord::Base
39
- extend FriendlyId
40
- has_many :restaurants
41
- friendly_id :name, :use => :slugged
42
- end
43
-
44
- class City < ActiveRecord::Base
45
- extend FriendlyId
46
- has_many :restaurants
47
- friendly_id :name, :use => :slugged
48
- end
49
-
50
- class Restaurant < ActiveRecord::Base
51
- extend FriendlyId
52
- belongs_to :city
53
- friendly_id :name, :use => :scoped, :scope => [:city, :cuisine]
54
- end
55
-
56
- All supplied values will be used to determine scope.
57
-
58
- ### Finding Records by Friendly ID
59
-
60
- If you are using scopes your friendly ids may not be unique, so a simple find
61
- like:
62
-
63
- Restaurant.friendly.find("joes-diner")
64
-
65
- may return the wrong record. In these cases it's best to query through the
66
- relation:
67
-
68
- @city.restaurants.friendly.find("joes-diner")
69
-
70
- Alternatively, you could pass the scope value as a query parameter:
71
-
72
- Restaurant.where(:city_id => @city.id).friendly.find("joes-diner")
73
-
74
-
75
- ### Finding All Records That Match a Scoped ID
76
-
77
- Query the slug column directly:
78
-
79
- Restaurant.where(:slug => "joes-diner")
80
-
81
- ### Routes for Scoped Models
82
-
83
- Recall that FriendlyId is a database-centric library, and does not set up any
84
- routes for scoped models. You must do this yourself in your application. Here's
85
- an example of one way to set this up:
86
-
87
- # in routes.rb
88
- resources :cities do
89
- resources :restaurants
90
- end
91
-
92
- # in views
93
- <%= link_to 'Show', [@city, @restaurant] %>
94
-
95
- # in controllers
96
- @city = City.friendly.find(params[:city_id])
97
- @restaurant = @city.restaurants.friendly.find(params[:id])
98
-
99
- # URLs:
100
- http://example.org/cities/seattle/restaurants/joes-diner
101
- http://example.org/cities/chicago/restaurants/joes-diner
102
-
103
- =end
4
+ # @guide begin
5
+ #
6
+ # ## Unique Slugs by Scope
7
+ #
8
+ # The {FriendlyId::Scoped} module allows FriendlyId to generate unique slugs
9
+ # within a scope.
10
+ #
11
+ # This allows, for example, two restaurants in different cities to have the slug
12
+ # `joes-diner`:
13
+ #
14
+ # class Restaurant < ActiveRecord::Base
15
+ # extend FriendlyId
16
+ # belongs_to :city
17
+ # friendly_id :name, :use => :scoped, :scope => :city
18
+ # end
19
+ #
20
+ # class City < ActiveRecord::Base
21
+ # extend FriendlyId
22
+ # has_many :restaurants
23
+ # friendly_id :name, :use => :slugged
24
+ # end
25
+ #
26
+ # City.friendly.find("seattle").restaurants.friendly.find("joes-diner")
27
+ # City.friendly.find("chicago").restaurants.friendly.find("joes-diner")
28
+ #
29
+ # Without :scoped in this case, one of the restaurants would have the slug
30
+ # `joes-diner` and the other would have `joes-diner-f9f3789a-daec-4156-af1d-fab81aa16ee5`.
31
+ #
32
+ # The value for the `:scope` option can be the name of a `belongs_to` relation, or
33
+ # a column.
34
+ #
35
+ # Additionally, the `:scope` option can receive an array of scope values:
36
+ #
37
+ # class Cuisine < ActiveRecord::Base
38
+ # extend FriendlyId
39
+ # has_many :restaurants
40
+ # friendly_id :name, :use => :slugged
41
+ # end
42
+ #
43
+ # class City < ActiveRecord::Base
44
+ # extend FriendlyId
45
+ # has_many :restaurants
46
+ # friendly_id :name, :use => :slugged
47
+ # end
48
+ #
49
+ # class Restaurant < ActiveRecord::Base
50
+ # extend FriendlyId
51
+ # belongs_to :city
52
+ # friendly_id :name, :use => :scoped, :scope => [:city, :cuisine]
53
+ # end
54
+ #
55
+ # All supplied values will be used to determine scope.
56
+ #
57
+ # ### Finding Records by Friendly ID
58
+ #
59
+ # If you are using scopes your friendly ids may not be unique, so a simple find
60
+ # like:
61
+ #
62
+ # Restaurant.friendly.find("joes-diner")
63
+ #
64
+ # may return the wrong record. In these cases it's best to query through the
65
+ # relation:
66
+ #
67
+ # @city.restaurants.friendly.find("joes-diner")
68
+ #
69
+ # Alternatively, you could pass the scope value as a query parameter:
70
+ #
71
+ # Restaurant.where(:city_id => @city.id).friendly.find("joes-diner")
72
+ #
73
+ #
74
+ # ### Finding All Records That Match a Scoped ID
75
+ #
76
+ # Query the slug column directly:
77
+ #
78
+ # Restaurant.where(:slug => "joes-diner")
79
+ #
80
+ # ### Routes for Scoped Models
81
+ #
82
+ # Recall that FriendlyId is a database-centric library, and does not set up any
83
+ # routes for scoped models. You must do this yourself in your application. Here's
84
+ # an example of one way to set this up:
85
+ #
86
+ # # in routes.rb
87
+ # resources :cities do
88
+ # resources :restaurants
89
+ # end
90
+ #
91
+ # # in views
92
+ # <%= link_to 'Show', [@city, @restaurant] %>
93
+ #
94
+ # # in controllers
95
+ # @city = City.friendly.find(params[:city_id])
96
+ # @restaurant = @city.restaurants.friendly.find(params[:id])
97
+ #
98
+ # # URLs:
99
+ # http://example.org/cities/seattle/restaurants/joes-diner
100
+ # http://example.org/cities/chicago/restaurants/joes-diner
101
+ #
102
+ # @guide end
104
103
  module Scoped
105
-
106
104
  # FriendlyId::Config.use will invoke this method when present, to allow
107
105
  # loading dependent modules prior to overriding them when necessary.
108
106
  def self.setup(model_class)
@@ -146,7 +144,6 @@ an example of one way to set this up:
146
144
  # This module adds the `:scope` configuration option to
147
145
  # {FriendlyId::Configuration FriendlyId::Configuration}.
148
146
  module Configuration
149
-
150
147
  # Gets the scope value.
151
148
  #
152
149
  # When setting this value, the argument should be a symbol referencing a
@@ -0,0 +1,69 @@
1
+ module FriendlyId
2
+ module SequentiallySlugged
3
+ class Calculator
4
+ attr_accessor :scope, :slug, :slug_column, :sequence_separator
5
+
6
+ def initialize(scope, slug, slug_column, sequence_separator, base_class)
7
+ @scope = scope
8
+ @slug = slug
9
+ table_name = scope.connection.quote_table_name(base_class.arel_table.name)
10
+ @slug_column = "#{table_name}.#{scope.connection.quote_column_name(slug_column)}"
11
+ @sequence_separator = sequence_separator
12
+ end
13
+
14
+ def next_slug
15
+ slug + sequence_separator + next_sequence_number.to_s
16
+ end
17
+
18
+ private
19
+
20
+ def conflict_query
21
+ base = "#{slug_column} = ? OR #{slug_column} LIKE ?"
22
+ # Awful hack for SQLite3, which does not pick up '\' as the escape character
23
+ # without this.
24
+ base << " ESCAPE '\\'" if /sqlite/i.match?(scope.connection.adapter_name)
25
+ base
26
+ end
27
+
28
+ def next_sequence_number
29
+ last_sequence_number ? last_sequence_number + 1 : 2
30
+ end
31
+
32
+ def last_sequence_number
33
+ # Reject slug_conflicts that doesn't come from the first_candidate
34
+ # Map all sequence numbers and take the maximum
35
+ slug_conflicts
36
+ .reject { |slug_conflict| !regexp.match(slug_conflict) }
37
+ .map { |slug_conflict| regexp.match(slug_conflict)[1].to_i }
38
+ .max
39
+ end
40
+
41
+ # Return the unnumbered (shortest) slug first, followed by the numbered ones
42
+ # in ascending order.
43
+ def ordering_query
44
+ "#{sql_length}(#{slug_column}) ASC, #{slug_column} ASC"
45
+ end
46
+
47
+ def regexp
48
+ /#{slug}#{sequence_separator}(\d+)\z/
49
+ end
50
+
51
+ def sequential_slug_matcher
52
+ # Underscores (matching a single character) and percent signs (matching
53
+ # any number of characters) need to be escaped. While this looks like
54
+ # an excessive number of backslashes, it is correct.
55
+ "#{slug}#{sequence_separator}".gsub(/[_%]/, '\\\\\&') + "%"
56
+ end
57
+
58
+ def slug_conflicts
59
+ scope
60
+ .where(conflict_query, slug, sequential_slug_matcher)
61
+ .order(Arel.sql(ordering_query)).pluck(Arel.sql(slug_column))
62
+ end
63
+
64
+ def sql_length
65
+ /sqlserver/i.match?(scope.connection.adapter_name) ? "LEN" : "LENGTH"
66
+ end
67
+ end
68
+ end
69
+ end
@@ -1,3 +1,5 @@
1
+ require_relative "sequentially_slugged/calculator"
2
+
1
3
  module FriendlyId
2
4
  module SequentiallySlugged
3
5
  def self.setup(model_class)
@@ -7,71 +9,14 @@ module FriendlyId
7
9
  def resolve_friendly_id_conflict(candidate_slugs)
8
10
  candidate = candidate_slugs.first
9
11
  return if candidate.nil?
10
- SequentialSlugCalculator.new(scope_for_slug_generator,
11
- candidate,
12
- friendly_id_config.slug_column,
13
- friendly_id_config.sequence_separator,
14
- slug_base_class).next_slug
15
- end
16
-
17
- class SequentialSlugCalculator
18
- attr_accessor :scope, :slug, :slug_column, :sequence_separator
19
-
20
- def initialize(scope, slug, slug_column, sequence_separator, base_class)
21
- @scope = scope
22
- @slug = slug
23
- table_name = scope.connection.quote_table_name(base_class.arel_table.name)
24
- @slug_column = "#{table_name}.#{scope.connection.quote_column_name(slug_column)}"
25
- @sequence_separator = sequence_separator
26
- end
27
-
28
- def next_slug
29
- slug + sequence_separator + next_sequence_number.to_s
30
- end
31
-
32
- private
33
-
34
- def next_sequence_number
35
- last_sequence_number ? last_sequence_number + 1 : 2
36
- end
37
-
38
- def last_sequence_number
39
- regexp = /#{slug}#{sequence_separator}(\d+)\z/
40
- # Reject slug_conflicts that doesn't come from the first_candidate
41
- # Map all sequence numbers and take the maximum
42
- slug_conflicts.reject{ |slug_conflict| !regexp.match(slug_conflict) }.map do |slug_conflict|
43
- regexp.match(slug_conflict)[1].to_i
44
- end.max
45
- end
46
12
 
47
- def slug_conflicts
48
- scope.
49
- where(conflict_query, slug, sequential_slug_matcher).
50
- order(Arel.sql(ordering_query)).pluck(Arel.sql(slug_column))
51
- end
52
-
53
- def conflict_query
54
- base = "#{slug_column} = ? OR #{slug_column} LIKE ?"
55
- # Awful hack for SQLite3, which does not pick up '\' as the escape character
56
- # without this.
57
- base << " ESCAPE '\\'" if scope.connection.adapter_name =~ /sqlite/i
58
- base
59
- end
60
-
61
- def sequential_slug_matcher
62
- # Underscores (matching a single character) and percent signs (matching
63
- # any number of characters) need to be escaped. While this looks like
64
- # an excessive number of backslashes, it is correct.
65
- "#{slug}#{sequence_separator}".gsub(/[_%]/, '\\\\\&') + '%'
66
- end
67
-
68
- # Return the unnumbered (shortest) slug first, followed by the numbered ones
69
- # in ascending order.
70
- def ordering_query
71
- length_command = "LENGTH"
72
- length_command = "LEN" if scope.connection.adapter_name =~ /sqlserver/i
73
- "#{length_command}(#{slug_column}) ASC, #{slug_column} ASC"
74
- end
13
+ Calculator.new(
14
+ scope_for_slug_generator,
15
+ candidate,
16
+ slug_column,
17
+ friendly_id_config.sequence_separator,
18
+ slug_base_class
19
+ ).next_slug
75
20
  end
76
21
 
77
22
  private
@@ -83,5 +28,13 @@ module FriendlyId
83
28
  self.class.base_class
84
29
  end
85
30
  end
31
+
32
+ def slug_column
33
+ if friendly_id_config.uses?(:history)
34
+ :slug
35
+ else
36
+ friendly_id_config.slug_column
37
+ end
38
+ end
86
39
  end
87
40
  end
@@ -1,75 +1,78 @@
1
1
  require "i18n"
2
2
 
3
3
  module FriendlyId
4
-
5
- =begin
6
-
7
- ## Translating Slugs Using Simple I18n
8
-
9
- The {FriendlyId::SimpleI18n SimpleI18n} module adds very basic i18n support to
10
- FriendlyId.
11
-
12
- In order to use this module, your model must have a slug column for each locale.
13
- By default FriendlyId looks for columns named, for example, "slug_en",
14
- "slug_es", etc. The first part of the name can be configured by passing the
15
- `:slug_column` option if you choose. Note that the column for the default locale
16
- must also include the locale in its name.
17
-
18
- This module is most suitable to applications that need to support few locales.
19
- If you need to support two or more locales, you may wish to use the
20
- friendly_id_globalize gem instead.
21
-
22
- ### Example migration
23
-
24
- def self.up
25
- create_table :posts do |t|
26
- t.string :title
27
- t.string :slug_en
28
- t.string :slug_es
29
- t.text :body
30
- end
31
- add_index :posts, :slug_en
32
- add_index :posts, :slug_es
33
- end
34
-
35
- ### Finds
36
-
37
- Finds will take into consideration the current locale:
38
-
39
- I18n.locale = :es
40
- Post.friendly.find("la-guerra-de-las-galaxias")
41
- I18n.locale = :en
42
- Post.friendly.find("star-wars")
43
-
44
- To find a slug by an explicit locale, perform the find inside a block
45
- passed to I18n's `with_locale` method:
46
-
47
- I18n.with_locale(:es) do
48
- Post.friendly.find("la-guerra-de-las-galaxias")
49
- end
50
-
51
- ### Creating Records
52
-
53
- When new records are created, the slug is generated for the current locale only.
54
-
55
- ### Translating Slugs
56
-
57
- To translate an existing record's friendly_id, use
58
- {FriendlyId::SimpleI18n::Model#set_friendly_id}. This will ensure that the slug
59
- you add is properly escaped, transliterated and sequenced:
60
-
61
- post = Post.create :name => "Star Wars"
62
- post.set_friendly_id("La guerra de las galaxias", :es)
63
-
64
- If you don't pass in a locale argument, FriendlyId::SimpleI18n will just use the
65
- current locale:
66
-
67
- I18n.with_locale(:es) do
68
- post.set_friendly_id("La guerra de las galaxias")
69
- end
70
- =end
4
+ # @guide begin
5
+ #
6
+ # ## Translating Slugs Using Simple I18n
7
+ #
8
+ # The {FriendlyId::SimpleI18n SimpleI18n} module adds very basic i18n support to
9
+ # FriendlyId.
10
+ #
11
+ # In order to use this module, your model must have a slug column for each locale.
12
+ # By default FriendlyId looks for columns named, for example, "slug_en",
13
+ # "slug_es", "slug_pt_br", etc. The first part of the name can be configured by
14
+ # passing the `:slug_column` option if you choose. Note that the column for the
15
+ # default locale must also include the locale in its name.
16
+ #
17
+ # This module is most suitable to applications that need to support few locales.
18
+ # If you need to support two or more locales, you may wish to use the
19
+ # friendly_id_globalize gem instead.
20
+ #
21
+ # ### Example migration
22
+ #
23
+ # def self.up
24
+ # create_table :posts do |t|
25
+ # t.string :title
26
+ # t.string :slug_en
27
+ # t.string :slug_es
28
+ # t.string :slug_pt_br
29
+ # t.text :body
30
+ # end
31
+ # add_index :posts, :slug_en
32
+ # add_index :posts, :slug_es
33
+ # add_index :posts, :slug_pt_br
34
+ # end
35
+ #
36
+ # ### Finds
37
+ #
38
+ # Finds will take into consideration the current locale:
39
+ #
40
+ # I18n.locale = :es
41
+ # Post.friendly.find("la-guerra-de-las-galaxias")
42
+ # I18n.locale = :en
43
+ # Post.friendly.find("star-wars")
44
+ # I18n.locale = :"pt-BR"
45
+ # Post.friendly.find("guerra-das-estrelas")
46
+ #
47
+ # To find a slug by an explicit locale, perform the find inside a block
48
+ # passed to I18n's `with_locale` method:
49
+ #
50
+ # I18n.with_locale(:es) do
51
+ # Post.friendly.find("la-guerra-de-las-galaxias")
52
+ # end
53
+ #
54
+ # ### Creating Records
55
+ #
56
+ # When new records are created, the slug is generated for the current locale only.
57
+ #
58
+ # ### Translating Slugs
59
+ #
60
+ # To translate an existing record's friendly_id, use
61
+ # {FriendlyId::SimpleI18n::Model#set_friendly_id}. This will ensure that the slug
62
+ # you add is properly escaped, transliterated and sequenced:
63
+ #
64
+ # post = Post.create :name => "Star Wars"
65
+ # post.set_friendly_id("La guerra de las galaxias", :es)
66
+ #
67
+ # If you don't pass in a locale argument, FriendlyId::SimpleI18n will just use the
68
+ # current locale:
69
+ #
70
+ # I18n.with_locale(:es) do
71
+ # post.set_friendly_id("La guerra de las galaxias")
72
+ # end
73
+ #
74
+ # @guide end
71
75
  module SimpleI18n
72
-
73
76
  # FriendlyId::Config.use will invoke this method when present, to allow
74
77
  # loading dependent modules prior to overriding them when necessary.
75
78
  def self.setup(model_class)
@@ -98,7 +101,13 @@ current locale:
98
101
 
99
102
  module Configuration
100
103
  def slug_column
101
- "#{super}_#{I18n.locale}"
104
+ "#{super}_#{locale_suffix}"
105
+ end
106
+
107
+ private
108
+
109
+ def locale_suffix
110
+ I18n.locale.to_s.underscore
102
111
  end
103
112
  end
104
113
  end
@@ -3,7 +3,7 @@ module FriendlyId
3
3
  #
4
4
  # @see FriendlyId::History
5
5
  class Slug < ActiveRecord::Base
6
- belongs_to :sluggable, :polymorphic => true
6
+ belongs_to :sluggable, polymorphic: true
7
7
 
8
8
  def sluggable
9
9
  sluggable_type.constantize.unscoped { super }
@@ -12,6 +12,5 @@ module FriendlyId
12
12
  def to_param
13
13
  slug
14
14
  end
15
-
16
15
  end
17
16
  end
@@ -2,7 +2,6 @@ module FriendlyId
2
2
  # The default slug generator offers functionality to check slug candidates for
3
3
  # availability.
4
4
  class SlugGenerator
5
-
6
5
  def initialize(scope, config)
7
6
  @scope = scope
8
7
  @config = config
@@ -17,9 +16,8 @@ module FriendlyId
17
16
  end
18
17
 
19
18
  def generate(candidates)
20
- candidates.each {|c| return c if available?(c)}
19
+ candidates.each { |c| return c if available?(c) }
21
20
  nil
22
21
  end
23
-
24
22
  end
25
23
  end