friendly_id 5.4.0 → 5.5.1

Sign up to get free protection for your applications and to get access to all the features.
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