lib/friendly_id/slugged.rb
require "friendly_id/slug_generator"
require "friendly_id/candidates"
module FriendlyId
# @guide begin
#
# ## Slugged Models
#
# FriendlyId can use a separate column to store slugs for models which require
# some text processing.
#
# For example, blog applications typically use a post title to provide the basis
# of a search engine friendly URL. Such identifiers typically lack uppercase
# characters, use ASCII to approximate UTF-8 characters, and strip out other
# characters which may make them aesthetically unappealing or error-prone when
# used in a URL.
#
# class Post < ActiveRecord::Base
# extend FriendlyId
# friendly_id :title, :use => :slugged
# end
#
# @post = Post.create(:title => "This is the first post!")
# @post.friendly_id # returns "this-is-the-first-post"
# redirect_to @post # the URL will be /posts/this-is-the-first-post
#
# In general, use slugs by default unless you know for sure you don't need them.
# To activate the slugging functionality, use the {FriendlyId::Slugged} module.
#
# FriendlyId will generate slugs from a method or column that you specify, and
# store them in a field in your model. By default, this field must be named
# `:slug`, though you may change this using the
# {FriendlyId::Slugged::Configuration#slug_column slug_column} configuration
# option. You should add an index to this column, and in most cases, make it
# unique. You may also wish to constrain it to NOT NULL, but this depends on your
# app's behavior and requirements.
#
# ### Example Setup
#
# # your model
# class Post < ActiveRecord::Base
# extend FriendlyId
# friendly_id :title, :use => :slugged
# validates_presence_of :title, :slug, :body
# end
#
# # a migration
# class CreatePosts < ActiveRecord::Migration
# def self.up
# create_table :posts do |t|
# t.string :title, :null => false
# t.string :slug, :null => false
# t.text :body
# end
#
# add_index :posts, :slug, :unique => true
# end
#
# def self.down
# drop_table :posts
# end
# end
#
# ### Working With Slugs
#
# #### Formatting
#
# By default, FriendlyId uses Active Support's
# [parameterize](http://api.rubyonrails.org/classes/ActiveSupport/Inflector.html#method-i-parameterize)
# method to create slugs. This method will intelligently replace spaces with
# dashes, and Unicode Latin characters with ASCII approximations:
#
# movie = Movie.create! :title => "Der Preis fürs Überleben"
# movie.slug #=> "der-preis-furs-uberleben"
#
# #### Column or Method?
#
# FriendlyId always uses a method as the basis of the slug text - not a column. At
# first glance, this may sound confusing, but remember that Active Record provides
# methods for each column in a model's associated table, and that's what
# FriendlyId uses.
#
# Here's an example of a class that uses a custom method to generate the slug:
#
# class Person < ActiveRecord::Base
# extend FriendlyId
# friendly_id :name_and_location, use: :slugged
#
# def name_and_location
# "#{name} from #{location}"
# end
# end
#
# bob = Person.create! :name => "Bob Smith", :location => "New York City"
# bob.friendly_id #=> "bob-smith-from-new-york-city"
#
# FriendlyId refers to this internally as the "base" method.
#
# #### Uniqueness
#
# When you try to insert a record that would generate a duplicate friendly id,
# FriendlyId will append a UUID to the generated slug to ensure uniqueness:
#
# car = Car.create :title => "Peugeot 206"
# car2 = Car.create :title => "Peugeot 206"
#
# car.friendly_id #=> "peugeot-206"
# car2.friendly_id #=> "peugeot-206-f9f3789a-daec-4156-af1d-fab81aa16ee5"
#
# Previous versions of FriendlyId appended a numeric sequence to make slugs
# unique, but this was removed to simplify using FriendlyId in concurrent code.
#
# #### Candidates
#
# Since UUIDs are ugly, FriendlyId provides a "slug candidates" functionality to
# let you specify alternate slugs to use in the event the one you want to use is
# already taken. For example:
#
# class Restaurant < ActiveRecord::Base
# extend FriendlyId
# friendly_id :slug_candidates, use: :slugged
#
# # Try building a slug based on the following fields in
# # increasing order of specificity.
# def slug_candidates
# [
# :name,
# [:name, :city],
# [:name, :street, :city],
# [:name, :street_number, :street, :city]
# ]
# end
# end
#
# r1 = Restaurant.create! name: 'Plaza Diner', city: 'New Paltz'
# r2 = Restaurant.create! name: 'Plaza Diner', city: 'Kingston'
#
# r1.friendly_id #=> 'plaza-diner'
# r2.friendly_id #=> 'plaza-diner-kingston'
#
# To use candidates, make your FriendlyId base method return an array. The
# method need not be named `slug_candidates`; it can be anything you want. The
# array may contain any combination of symbols, strings, procs or lambdas and
# will be evaluated lazily and in order. If you include symbols, FriendlyId will
# invoke a method on your model class with the same name. Strings will be
# interpreted literally. Procs and lambdas will be called and their return values
# used as the basis of the friendly id. If none of the candidates can generate a
# unique slug, then FriendlyId will append a UUID to the first candidate as a
# last resort.
#
# #### Sequence Separator
#
# By default, FriendlyId uses a dash to separate the slug from a sequence.
#
# You can change this with the {FriendlyId::Slugged::Configuration#sequence_separator
# sequence_separator} configuration option.
#
# #### Providing Your Own Slug Processing Method
#
# You can override {FriendlyId::Slugged#normalize_friendly_id} in your model for
# total control over the slug format. It will be invoked for any generated slug,
# whether for a single slug or for slug candidates.
#
# #### Deciding When to Generate New Slugs
#
# As of FriendlyId 5.0, slugs are only generated when the `slug` field is nil. If
# you want a slug to be regenerated,set the slug field to nil:
#
# restaurant.friendly_id # joes-diner
# restaurant.name = "The Plaza Diner"
# restaurant.save!
# restaurant.friendly_id # joes-diner
# restaurant.slug = nil
# restaurant.save!
# restaurant.friendly_id # the-plaza-diner
#
# You can also override the
# {FriendlyId::Slugged#should_generate_new_friendly_id?} method, which lets you
# control exactly when new friendly ids are set:
#
# class Post < ActiveRecord::Base
# extend FriendlyId
# friendly_id :title, :use => :slugged
#
# def should_generate_new_friendly_id?
# title_changed?
# end
# end
#
# If you want to extend the default behavior but add your own conditions,
# don't forget to invoke `super` from your implementation:
#
# class Category < ActiveRecord::Base
# extend FriendlyId
# friendly_id :name, :use => :slugged
#
# def should_generate_new_friendly_id?
# name_changed? || super
# end
# end
#
# #### Locale-specific Transliterations
#
# Active Support's `parameterize` uses
# [transliterate](http://api.rubyonrails.org/classes/ActiveSupport/Inflector.html#method-i-transliterate),
# which in turn can use I18n's transliteration rules to consider the current
# locale when replacing Latin characters:
#
# # config/locales/de.yml
# de:
# i18n:
# transliterate:
# rule:
# ü: "ue"
# ö: "oe"
# etc...
#
# movie = Movie.create! :title => "Der Preis fürs Überleben"
# movie.slug #=> "der-preis-fuers-ueberleben"
#
# This functionality was in fact taken from earlier versions of FriendlyId.
#
# #### Gotchas: Common Problems
#
# FriendlyId uses a before_validation callback to generate and set the slug. This
# means that if you create two model instances before saving them, it's possible
# they will generate the same slug, and the second save will fail.
#
# This can happen in two fairly normal cases: the first, when a model using nested
# attributes creates more than one record for a model that uses friendly_id. The
# second, in concurrent code, either in threads or multiple processes.
#
# To solve the nested attributes issue, I recommend simply avoiding them when
# creating more than one nested record for a model that uses FriendlyId. See [this
# Github issue](https://github.com/norman/friendly_id/issues/185) for discussion.
#
# @guide end
module Slugged
# Sets up behavior and configuration options for FriendlyId's slugging
# feature.
def self.included(model_class)
model_class.friendly_id_config.instance_eval do
self.class.send :include, Configuration
self.slug_generator_class ||= SlugGenerator
defaults[:slug_column] ||= "slug"
defaults[:sequence_separator] ||= "-"
end
model_class.before_validation :set_slug
model_class.before_save :set_slug
model_class.after_validation :unset_slug_if_invalid
end
# Process the given value to make it suitable for use as a slug.
#
# This method is not intended to be invoked directly; FriendlyId uses it
# internally to process strings into slugs.
#
# However, if FriendlyId's default slug generation doesn't suit your needs,
# you can override this method in your model class to control exactly how
# slugs are generated.
#
# ### Example
#
# class Person < ActiveRecord::Base
# extend FriendlyId
# friendly_id :name_and_location
#
# def name_and_location
# "#{name} from #{location}"
# end
#
# # Use default slug, but upper case and with underscores
# def normalize_friendly_id(string)
# super.upcase.gsub("-", "_")
# end
# end
#
# bob = Person.create! :name => "Bob Smith", :location => "New York City"
# bob.friendly_id #=> "BOB_SMITH_FROM_NEW_YORK_CITY"
#
# ### More Resources
#
# You might want to look into Babosa[https://github.com/norman/babosa],
# which is the slugging library used by FriendlyId prior to version 4, which
# offers some specialized functionality missing from Active Support.
#
# @param [#to_s] value The value used as the basis of the slug.
# @return The candidate slug text, without a sequence.
def normalize_friendly_id(value)
value = value.to_s.parameterize
value = value[0...friendly_id_config.slug_limit] if friendly_id_config.slug_limit
value
end
# Whether to generate a new slug.
#
# You can override this method in your model if, for example, you only want
# slugs to be generated once, and then never updated.
def should_generate_new_friendly_id?
send(friendly_id_config.slug_column).nil? && !send(friendly_id_config.base).nil?
end
# Public: Resolve conflicts.
#
# This method adds UUID to first candidate and truncates (if `slug_limit` is set).
#
# Examples:
#
# resolve_friendly_id_conflict(['12345'])
# # => '12345-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
#
# FriendlyId.defaults { |config| config.slug_limit = 40 }
# resolve_friendly_id_conflict(['12345'])
# # => '123-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
#
# candidates - the Array with candidates.
#
# Returns the String with new slug.
def resolve_friendly_id_conflict(candidates)
uuid = SecureRandom.uuid
[
apply_slug_limit(candidates.first, uuid),
uuid
].compact.join(friendly_id_config.sequence_separator)
end
# Private: Apply slug limit to candidate.
#
# candidate - the String with candidate.
# uuid - the String with UUID.
#
# Return the String with truncated candidate.
def apply_slug_limit(candidate, uuid)
return candidate unless candidate && friendly_id_config.slug_limit
candidate[0...candidate_limit(uuid)]
end
private :apply_slug_limit
# Private: Get max length of candidate.
#
# uuid - the String with UUID.
#
# Returns the Integer with max length.
def candidate_limit(uuid)
[
friendly_id_config.slug_limit - uuid.size - friendly_id_config.sequence_separator.size,
0
].max
end
private :candidate_limit
# Sets the slug.
def set_slug(normalized_slug = nil)
if should_generate_new_friendly_id?
candidates = FriendlyId::Candidates.new(self, normalized_slug || send(friendly_id_config.base))
slug = slug_generator.generate(candidates) || resolve_friendly_id_conflict(candidates)
send "#{friendly_id_config.slug_column}=", slug
end
end
private :set_slug
def scope_for_slug_generator
scope = self.class.base_class.unscoped
scope = scope.friendly unless scope.respond_to?(:exists_by_friendly_id?)
primary_key_name = self.class.primary_key
scope.where(self.class.base_class.arel_table[primary_key_name].not_eq(send(primary_key_name)))
end
private :scope_for_slug_generator
def slug_generator
friendly_id_config.slug_generator_class.new(scope_for_slug_generator, friendly_id_config)
end
private :slug_generator
def unset_slug_if_invalid
if errors.key?(friendly_id_config.query_field) && attribute_changed?(friendly_id_config.query_field.to_s)
diff = changes[friendly_id_config.query_field]
send "#{friendly_id_config.slug_column}=", diff.first
end
end
private :unset_slug_if_invalid
# This module adds the `:slug_column`, and `:slug_limit`, and `:sequence_separator`,
# and `:slug_generator_class` configuration options to
# {FriendlyId::Configuration FriendlyId::Configuration}.
module Configuration
attr_writer :slug_column, :slug_limit, :sequence_separator
attr_accessor :slug_generator_class
# Makes FriendlyId use the slug column for querying.
# @return String The slug column.
def query_field
slug_column
end
# The string used to separate a slug base from a numeric sequence.
#
# You can change the default separator by setting the
# {FriendlyId::Slugged::Configuration#sequence_separator
# sequence_separator} configuration option.
# @return String The sequence separator string. Defaults to "`-`".
def sequence_separator
@sequence_separator ||= defaults[:sequence_separator]
end
# The column that will be used to store the generated slug.
def slug_column
@slug_column ||= defaults[:slug_column]
end
# The limit that will be used for slug.
def slug_limit
@slug_limit ||= defaults[:slug_limit]
end
end
end
end