├── .gemtest ├── lib ├── friendly_id │ ├── .gitattributes │ ├── version.rb │ ├── slug.rb │ ├── slug_generator.rb │ ├── migration.rb │ ├── reserved.rb │ ├── candidates.rb │ ├── finder_methods.rb │ ├── object_utils.rb │ ├── sequentially_slugged.rb │ ├── simple_i18n.rb │ ├── finders.rb │ ├── configuration.rb │ ├── initializer.rb │ ├── history.rb │ ├── scoped.rb │ ├── base.rb │ └── slugged.rb ├── generators │ └── friendly_id_generator.rb └── friendly_id.rb ├── .yardopts ├── .gitignore ├── CONTRIBUTING.md ├── Gemfile ├── test ├── databases.yml ├── object_utils_test.rb ├── numeric_slug_test.rb ├── core_test.rb ├── generator_test.rb ├── finders_test.rb ├── configuration_test.rb ├── base_test.rb ├── reserved_test.rb ├── benchmarks │ ├── finders.rb │ └── object_utils.rb ├── helper.rb ├── scoped_test.rb ├── schema.rb ├── sti_test.rb ├── candidates_test.rb ├── simple_i18n_test.rb ├── sequentially_slugged_test.rb ├── shared.rb ├── history_test.rb └── slugged_test.rb ├── gemfiles ├── Gemfile.rails-5.1.rb ├── Gemfile.rails-5.2.rb ├── Gemfile.rails-6.0.rb └── Gemfile.rails-5.0.rb ├── guide.rb ├── .github ├── stale.yml └── workflows │ └── test.yml ├── MIT-LICENSE ├── friendly_id.gemspec ├── bench.rb ├── Rakefile ├── UPGRADING.md ├── README.md └── Changelog.md /.gemtest: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/friendly_id/.gitattributes: -------------------------------------------------------------------------------- 1 | version.rb merge=ours 2 | -------------------------------------------------------------------------------- /lib/friendly_id/version.rb: -------------------------------------------------------------------------------- 1 | module FriendlyId 2 | VERSION = '5.3.0'.freeze 3 | end 4 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | -e guide.rb 2 | --files=Changelog.md,Guide.md 3 | --private 4 | --protected 5 | --exclude lib/friendly_id/migration 6 | --markup=markdown 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | doc 3 | docs 4 | pkg 5 | .DS_Store 6 | coverage 7 | .yardoc 8 | *.gem 9 | *.sqlite3 10 | *.rbc 11 | *.lock 12 | .rbx 13 | Guide.md 14 | .friendly_id 15 | -------------------------------------------------------------------------------- /lib/friendly_id/slug.rb: -------------------------------------------------------------------------------- 1 | module FriendlyId 2 | # A FriendlyId slug stored in an external table. 3 | # 4 | # @see FriendlyId::History 5 | class Slug < ActiveRecord::Base 6 | belongs_to :sluggable, :polymorphic => true 7 | 8 | def sluggable 9 | sluggable_type.constantize.unscoped { super } 10 | end 11 | 12 | def to_param 13 | slug 14 | end 15 | 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # FriendlyId 2 | 3 | Please ask questions on [Stack 4 | Overflow](http://stackoverflow.com/questions/tagged/friendly-id) using the 5 | "friendly_id" or "friendly-id" tag. Prior to asking, search and see if your 6 | question has already been answered. 7 | 8 | Please only post issues in Github issues for actual bugs. 9 | 10 | I am asking people to do this because the same questions keep getting asked 11 | over and over and over again in the issues. 12 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | gem 'rake' 6 | 7 | group :development, :test do 8 | platforms :ruby do 9 | gem 'byebug' 10 | gem 'pry' 11 | end 12 | 13 | platforms :jruby do 14 | gem 'activerecord-jdbcsqlite3-adapter', '>= 1.3.0.beta2' 15 | gem 'kramdown' 16 | end 17 | 18 | platforms :ruby, :rbx do 19 | gem 'sqlite3' 20 | gem 'redcarpet' 21 | end 22 | 23 | platforms :rbx do 24 | gem 'rubysl', '~> 2.0' 25 | gem 'rubinius-developer_tools' 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/databases.yml: -------------------------------------------------------------------------------- 1 | mysql: 2 | adapter: mysql2 3 | database: friendly_id_test 4 | username: root 5 | password: <%= ENV['MYSQL_PASSWORD'] %> 6 | hostname: localhost 7 | encoding: utf8 8 | 9 | postgres: 10 | adapter: postgresql 11 | host: <%= ENV.fetch('PGHOST') { 'localhost' } %> 12 | port: <%= ENV.fetch('PGPORT') { '5432' } %> 13 | username: <%= ENV.fetch('PGUSER') { 'postgres' } %> 14 | database: friendly_id_test 15 | encoding: utf8 16 | 17 | sqlite3: 18 | adapter: sqlite3 19 | database: ":memory:" 20 | encoding: utf8 21 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.rails-5.1.rb: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec path: '../' 4 | 5 | gem 'activerecord', '~> 5.1.0' 6 | gem 'railties', '~> 5.1.0' 7 | 8 | # Database Configuration 9 | group :development, :test do 10 | platforms :jruby do 11 | gem 'activerecord-jdbcmysql-adapter', '~> 50.1' 12 | gem 'activerecord-jdbcpostgresql-adapter', '~> 50.1' 13 | gem 'kramdown' 14 | end 15 | 16 | platforms :ruby, :rbx do 17 | gem 'sqlite3' 18 | gem 'mysql2' 19 | gem 'pg' 20 | gem 'redcarpet' 21 | end 22 | 23 | platforms :rbx do 24 | gem 'rubysl', '~> 2.0' 25 | gem 'rubinius-developer_tools' 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.rails-5.2.rb: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec path: '../' 4 | 5 | gem 'activerecord', '~> 5.2.0' 6 | gem 'railties', '~> 5.2.0' 7 | 8 | # Database Configuration 9 | group :development, :test do 10 | platforms :jruby do 11 | gem 'activerecord-jdbcmysql-adapter', '~> 51.1' 12 | gem 'activerecord-jdbcpostgresql-adapter', '~> 51.1' 13 | gem 'kramdown' 14 | end 15 | 16 | platforms :ruby, :rbx do 17 | gem 'sqlite3' 18 | gem 'mysql2' 19 | gem 'pg' 20 | gem 'redcarpet' 21 | end 22 | 23 | platforms :rbx do 24 | gem 'rubysl', '~> 2.0' 25 | gem 'rubinius-developer_tools' 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.rails-6.0.rb: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec path: '../' 4 | 5 | gem 'activerecord', '~> 6.0.0' 6 | gem 'railties', '~> 6.0.0' 7 | 8 | # Database Configuration 9 | group :development, :test do 10 | platforms :jruby do 11 | gem 'activerecord-jdbcmysql-adapter', '~> 51.1' 12 | gem 'activerecord-jdbcpostgresql-adapter', '~> 51.1' 13 | gem 'kramdown' 14 | end 15 | 16 | platforms :ruby, :rbx do 17 | gem 'sqlite3' 18 | gem 'mysql2' 19 | gem 'pg' 20 | gem 'redcarpet' 21 | end 22 | 23 | platforms :rbx do 24 | gem 'rubysl', '~> 2.0' 25 | gem 'rubinius-developer_tools' 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /guide.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # This script generates the Guide.md file included in the Yard docs. 4 | 5 | def comments_from path 6 | path = File.expand_path("../lib/friendly_id/#{path}", __FILE__) 7 | match = File.read(path).match(/\n=begin(.*)\n=end/m)[1].to_s 8 | match.split("\n").reject {|x| x =~ /^@/}.join("\n").strip 9 | end 10 | 11 | File.open(File.expand_path('../Guide.md', __FILE__), 'w:utf-8') do |guide| 12 | ['../friendly_id.rb', 'base.rb', 'finders.rb', 'slugged.rb', 'history.rb', 13 | 'scoped.rb', 'simple_i18n.rb', 'reserved.rb'].each do |file| 14 | guide.write comments_from file 15 | guide.write "\n" 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.rails-5.0.rb: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec path: '../' 4 | 5 | gem 'activerecord', '~> 5.0.0' 6 | gem 'railties', '~> 5.0.0' 7 | gem 'i18n', '~> 0.7.0' 8 | 9 | # Database Configuration 10 | group :development, :test do 11 | platforms :jruby do 12 | gem 'activerecord-jdbcmysql-adapter', '~> 50.1' 13 | gem 'activerecord-jdbcpostgresql-adapter', '~> 50.1' 14 | gem 'kramdown' 15 | end 16 | 17 | platforms :ruby, :rbx do 18 | gem 'sqlite3' 19 | gem 'mysql2' 20 | gem 'pg' 21 | gem 'redcarpet' 22 | end 23 | 24 | platforms :rbx do 25 | gem 'rubysl', '~> 2.0' 26 | gem 'rubinius-developer_tools' 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/friendly_id/slug_generator.rb: -------------------------------------------------------------------------------- 1 | module FriendlyId 2 | # The default slug generator offers functionality to check slug candidates for 3 | # availability. 4 | class SlugGenerator 5 | 6 | def initialize(scope, config) 7 | @scope = scope 8 | @config = config 9 | end 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 | 16 | !@scope.exists_by_friendly_id?(slug) 17 | end 18 | 19 | def generate(candidates) 20 | candidates.each {|c| return c if available?(c)} 21 | nil 22 | end 23 | 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 42 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: stale 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /test/object_utils_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | 4 | class ObjectUtilsTest < TestCaseClass 5 | 6 | include FriendlyId::Test 7 | 8 | test "strings with letters are friendly_ids" do 9 | assert "a".friendly_id? 10 | end 11 | 12 | test "integers should be unfriendly ids" do 13 | assert 1.unfriendly_id? 14 | end 15 | 16 | test "numeric strings are neither friendly nor unfriendly" do 17 | assert_nil "1".friendly_id? 18 | assert_nil "1".unfriendly_id? 19 | end 20 | 21 | test "ActiveRecord::Base instances should be unfriendly_ids" do 22 | FriendlyId.mark_as_unfriendly(ActiveRecord::Base) 23 | 24 | model_class = Class.new(ActiveRecord::Base) do 25 | self.table_name = "authors" 26 | end 27 | assert model_class.new.unfriendly_id? 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/numeric_slug_test.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | class NumericSlugTest < TestCaseClass 4 | include FriendlyId::Test 5 | include FriendlyId::Test::Shared::Core 6 | 7 | def model_class 8 | Article 9 | end 10 | 11 | test "should generate numeric slugs" do 12 | transaction do 13 | record = model_class.create! :name => "123" 14 | assert_equal "123", record.slug 15 | end 16 | end 17 | 18 | test "should find by numeric slug" do 19 | transaction do 20 | record = model_class.create! :name => "123" 21 | assert_equal model_class.friendly.find("123").id, record.id 22 | end 23 | end 24 | 25 | test "should exist? by numeric slug" do 26 | transaction do 27 | record = model_class.create! :name => "123" 28 | assert model_class.friendly.exists?("123") 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/friendly_id/migration.rb: -------------------------------------------------------------------------------- 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 9 | def change 10 | create_table :friendly_id_slugs do |t| 11 | t.string :slug, :null => false 12 | t.integer :sluggable_id, :null => false 13 | t.string :sluggable_type, :limit => 50 14 | t.string :scope 15 | t.datetime :created_at 16 | end 17 | add_index :friendly_id_slugs, [:sluggable_type, :sluggable_id] 18 | add_index :friendly_id_slugs, [:slug, :sluggable_type], length: { slug: 140, sluggable_type: 50 } 19 | add_index :friendly_id_slugs, [:slug, :sluggable_type, :scope], length: { slug: 70, sluggable_type: 50, scope: 70 }, unique: true 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/core_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | class Book < ActiveRecord::Base 4 | extend FriendlyId 5 | friendly_id :name 6 | end 7 | 8 | class Author < ActiveRecord::Base 9 | extend FriendlyId 10 | friendly_id :name 11 | has_many :books 12 | end 13 | 14 | class CoreTest < TestCaseClass 15 | 16 | include FriendlyId::Test 17 | include FriendlyId::Test::Shared::Core 18 | 19 | def model_class 20 | Author 21 | end 22 | 23 | test "models don't use friendly_id by default" do 24 | assert !Class.new(ActiveRecord::Base) { 25 | self.abstract_class = true 26 | }.respond_to?(:friendly_id) 27 | end 28 | 29 | test "model classes should have a friendly id config" do 30 | assert model_class.friendly_id(:name).friendly_id_config 31 | end 32 | 33 | test "instances should have a friendly id" do 34 | with_instance_of(model_class) {|record| assert record.friendly_id} 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2008-2016 Norman Clarke, Adrian Mugnolo and Emilio Tagua. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /lib/generators/friendly_id_generator.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators' 2 | require "rails/generators/active_record" 3 | 4 | # This generator adds a migration for the {FriendlyId::History 5 | # FriendlyId::History} addon. 6 | class FriendlyIdGenerator < ActiveRecord::Generators::Base 7 | # ActiveRecord::Generators::Base inherits from Rails::Generators::NamedBase which requires a NAME parameter for the 8 | # new table name. Our generator always uses 'friendly_id_slugs', so we just set a random name here. 9 | argument :name, type: :string, default: 'random_name' 10 | 11 | class_option :'skip-migration', :type => :boolean, :desc => "Don't generate a migration for the slugs table" 12 | class_option :'skip-initializer', :type => :boolean, :desc => "Don't generate an initializer" 13 | 14 | source_root File.expand_path('../../friendly_id', __FILE__) 15 | 16 | # Copies the migration template to db/migrate. 17 | def copy_files 18 | return if options['skip-migration'] 19 | migration_template 'migration.rb', 'db/migrate/create_friendly_id_slugs.rb' 20 | end 21 | 22 | def create_initializer 23 | return if options['skip-initializer'] 24 | copy_file 'initializer.rb', 'config/initializers/friendly_id.rb' 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/generator_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | require "rails/generators" 3 | require "generators/friendly_id_generator" 4 | 5 | class FriendlyIdGeneratorTest < Rails::Generators::TestCase 6 | 7 | tests FriendlyIdGenerator 8 | destination File.expand_path("../../tmp", __FILE__) 9 | 10 | setup :prepare_destination 11 | 12 | test "should generate a migration" do 13 | begin 14 | run_generator 15 | assert_migration "db/migrate/create_friendly_id_slugs" 16 | ensure 17 | FileUtils.rm_rf self.destination_root 18 | end 19 | end 20 | 21 | test "should skip the migration when told to do so" do 22 | begin 23 | run_generator ['--skip-migration'] 24 | assert_no_migration "db/migrate/create_friendly_id_slugs" 25 | ensure 26 | FileUtils.rm_rf self.destination_root 27 | end 28 | end 29 | 30 | test "should generate an initializer" do 31 | begin 32 | run_generator 33 | assert_file "config/initializers/friendly_id.rb" 34 | ensure 35 | FileUtils.rm_rf self.destination_root 36 | end 37 | end 38 | 39 | test "should skip the initializer when told to do so" do 40 | begin 41 | run_generator ['--skip-initializer'] 42 | assert_no_file "config/initializers/friendly_id.rb" 43 | ensure 44 | FileUtils.rm_rf self.destination_root 45 | end 46 | end 47 | 48 | end 49 | -------------------------------------------------------------------------------- /friendly_id.gemspec: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require File.expand_path("../lib/friendly_id/version", __FILE__) 3 | 4 | Gem::Specification.new do |s| 5 | s.name = "friendly_id" 6 | s.version = FriendlyId::VERSION 7 | s.authors = ["Norman Clarke", "Philip Arndt"] 8 | s.email = ["norman@njclarke.com", "p@arndt.io"] 9 | s.homepage = "https://github.com/norman/friendly_id" 10 | s.summary = "A comprehensive slugging and pretty-URL plugin." 11 | s.files = `git ls-files`.split("\n") 12 | s.test_files = `git ls-files -- {test}/*`.split("\n") 13 | s.require_paths = ["lib"] 14 | s.license = 'MIT' 15 | 16 | s.required_ruby_version = '>= 2.1.0' 17 | 18 | s.add_dependency 'activerecord', '>= 4.0.0' 19 | 20 | s.add_development_dependency 'coveralls' 21 | s.add_development_dependency 'railties', '>= 4.0' 22 | s.add_development_dependency 'minitest', '~> 5.3' 23 | s.add_development_dependency 'mocha', '~> 1.1' 24 | s.add_development_dependency 'yard' 25 | s.add_development_dependency 'i18n' 26 | s.add_development_dependency 'ffaker' 27 | s.add_development_dependency 'simplecov' 28 | 29 | s.description = <<-EOM 30 | FriendlyId is the "Swiss Army bulldozer" of slugging and permalink plugins for 31 | Active Record. It lets you create pretty URLs and work with human-friendly 32 | strings as if they were numeric ids. 33 | EOM 34 | end 35 | -------------------------------------------------------------------------------- /test/finders_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | class JournalistWithFriendlyFinders < ActiveRecord::Base 4 | self.table_name = 'journalists' 5 | extend FriendlyId 6 | scope :existing, -> {where('1 = 1')} 7 | friendly_id :name, use: [:slugged, :finders] 8 | end 9 | 10 | class Finders < TestCaseClass 11 | 12 | include FriendlyId::Test 13 | 14 | def model_class 15 | JournalistWithFriendlyFinders 16 | end 17 | 18 | test 'should find records with finders as class methods' do 19 | with_instance_of(model_class) do |record| 20 | assert model_class.find(record.friendly_id) 21 | end 22 | end 23 | 24 | test 'should find records with finders on relations' do 25 | with_instance_of(model_class) do |record| 26 | assert model_class.existing.find(record.friendly_id) 27 | end 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 53 | end 54 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'master' 7 | pull_request: 8 | jobs: 9 | test: 10 | strategy: 11 | matrix: 12 | architecture: [ x64 ] 13 | database: [ mysql, postgresql ] 14 | gemfile: [ '6.0', '5.2', '5.1', '5.0' ] 15 | ruby: [ '2.6.x', '2.5.x' ] 16 | fail-fast: false 17 | runs-on: ubuntu-latest 18 | name: ${{ matrix.ruby }} ${{ matrix.database }} rails-${{ matrix.gemfile }} 19 | steps: 20 | - uses: actions/setup-ruby@v1.0.0 21 | with: 22 | architecture: ${{ matrix.architecture }} 23 | ruby-version: ${{ matrix.ruby }} 24 | version: ${{ matrix.ruby }} 25 | - uses: actions/checkout@v1 26 | - run: sudo apt-get update && sudo apt-get install libpq-dev postgresql-client libmysqlclient-dev libsqlite3-dev -y 27 | - id: cache-bundler 28 | uses: actions/cache@v1 29 | with: 30 | path: vendor/bundle 31 | key: ${{ matrix.ruby }}-gem-${{ hashFiles(format('gemfiles/Gemfile.rails-{0}.rb', matrix.gemfile)) }} 32 | - run: gem install bundler 33 | - run: bundle install --path vendor/bundle 34 | - run: bundle exec rake db:create db:up 35 | - run: bundle exec rake test 36 | 37 | env: 38 | BUNDLE_JOBS: 4 39 | BUNDLE_GEMFILE: gemfiles/Gemfile.rails-${{ matrix.gemfile }}.rb 40 | BUNDLE_PATH: vendor/bundle 41 | CI: true 42 | COVERALLS: true 43 | DB: ${{ matrix.database }} 44 | MYSQL_PASSWORD: root 45 | PGHOST: localhost 46 | PGPORT: 5432 47 | PGUSER: postgres 48 | RAILS_ENV: test 49 | 50 | services: 51 | postgres: 52 | image: postgres:11.5 53 | ports: ["5432:5432"] 54 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 55 | 56 | -------------------------------------------------------------------------------- /lib/friendly_id/reserved.rb: -------------------------------------------------------------------------------- 1 | module FriendlyId 2 | 3 | =begin 4 | 5 | ## Reserved Words 6 | 7 | The {FriendlyId::Reserved Reserved} module adds the ability to exclude a list of 8 | words from use as FriendlyId slugs. 9 | 10 | With Ruby on Rails, FriendlyId's generator generates an initializer that 11 | reserves some words such as "new" and "edit" using {FriendlyId.defaults 12 | FriendlyId.defaults}. 13 | 14 | Note that the error messages for fields will appear on the field 15 | `:friendly_id`. If you are using Rails's scaffolded form errors display, then 16 | it will have no field to highlight. If you'd like to change this so that 17 | scaffolding works as expected, one way to accomplish this is to move the error 18 | message to a different field. For example: 19 | 20 | class Person < ActiveRecord::Base 21 | extend FriendlyId 22 | friendly_id :name, use: :slugged 23 | 24 | after_validation :move_friendly_id_error_to_name 25 | 26 | def move_friendly_id_error_to_name 27 | errors.add :name, *errors.delete(:friendly_id) if errors[:friendly_id].present? 28 | end 29 | end 30 | 31 | =end 32 | module Reserved 33 | 34 | # When included, this module adds configuration options to the model class's 35 | # friendly_id_config. 36 | def self.included(model_class) 37 | model_class.class_eval do 38 | friendly_id_config.class.send :include, Reserved::Configuration 39 | validates_exclusion_of :friendly_id, :in => ->(_) { 40 | friendly_id_config.reserved_words || [] 41 | } 42 | end 43 | end 44 | 45 | # This module adds the `:reserved_words` configuration option to 46 | # {FriendlyId::Configuration FriendlyId::Configuration}. 47 | module Configuration 48 | attr_accessor :reserved_words 49 | attr_accessor :treat_reserved_as_conflict 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/configuration_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | class ConfigurationTest < TestCaseClass 4 | 5 | include FriendlyId::Test 6 | 7 | def setup 8 | @model_class = Class.new(ActiveRecord::Base) do 9 | self.abstract_class = true 10 | end 11 | end 12 | 13 | test "should set model class on initialization" do 14 | config = FriendlyId::Configuration.new @model_class 15 | assert_equal @model_class, config.model_class 16 | end 17 | 18 | test "should set options on initialization if present" do 19 | config = FriendlyId::Configuration.new @model_class, :base => "hello" 20 | assert_equal "hello", config.base 21 | end 22 | 23 | test "should raise error if passed unrecognized option" do 24 | assert_raises NoMethodError do 25 | FriendlyId::Configuration.new @model_class, :foo => "bar" 26 | end 27 | end 28 | 29 | test "#use should accept a name that resolves to a module" do 30 | refute @model_class < FriendlyId::Slugged 31 | @model_class.class_eval do 32 | extend FriendlyId 33 | friendly_id :hello, :use => :slugged 34 | end 35 | assert @model_class < FriendlyId::Slugged 36 | end 37 | 38 | test "#use should accept a module" do 39 | my_module = Module.new 40 | refute @model_class < my_module 41 | @model_class.class_eval do 42 | extend FriendlyId 43 | friendly_id :hello, :use => my_module 44 | end 45 | assert @model_class < my_module 46 | end 47 | 48 | test "#base should optionally set a value" do 49 | config = FriendlyId::Configuration.new @model_class 50 | assert_nil config.base 51 | config.base = 'foo' 52 | assert_equal 'foo', config.base 53 | end 54 | 55 | test "#base can set the value to nil" do 56 | config = FriendlyId::Configuration.new @model_class 57 | config.base 'foo' 58 | config.base nil 59 | assert_nil config.base 60 | 61 | end 62 | 63 | 64 | end 65 | -------------------------------------------------------------------------------- /lib/friendly_id/candidates.rb: -------------------------------------------------------------------------------- 1 | require 'securerandom' 2 | 3 | module FriendlyId 4 | 5 | # This class provides the slug candidate functionality. 6 | # @see FriendlyId::Slugged 7 | class Candidates 8 | 9 | include Enumerable 10 | 11 | def initialize(object, *array) 12 | @object = object 13 | @raw_candidates = to_candidate_array(object, array.flatten(1)) 14 | end 15 | 16 | def each(*args, &block) 17 | return candidates unless block_given? 18 | candidates.each{ |candidate| yield candidate } 19 | end 20 | 21 | private 22 | 23 | def candidates 24 | @candidates ||= begin 25 | candidates = normalize(@raw_candidates) 26 | filter(candidates) 27 | end 28 | end 29 | 30 | def normalize(candidates) 31 | candidates.map do |candidate| 32 | @object.normalize_friendly_id(candidate.map(&:call).join(' ')) 33 | end.select {|x| wanted?(x)} 34 | end 35 | 36 | def filter(candidates) 37 | unless candidates.all? {|x| reserved?(x)} 38 | candidates.reject! {|x| reserved?(x)} 39 | end 40 | candidates 41 | end 42 | 43 | def to_candidate_array(object, array) 44 | array.map do |candidate| 45 | case candidate 46 | when String 47 | [->{candidate}] 48 | when Array 49 | to_candidate_array(object, candidate).flatten 50 | when Symbol 51 | [object.method(candidate)] 52 | else 53 | if candidate.respond_to?(:call) 54 | [candidate] 55 | else 56 | [->{candidate.to_s}] 57 | end 58 | end 59 | end 60 | end 61 | 62 | def wanted?(slug) 63 | slug.present? 64 | end 65 | 66 | def reserved?(slug) 67 | config = @object.friendly_id_config 68 | return false unless config.uses? ::FriendlyId::Reserved 69 | return false unless config.reserved_words 70 | config.reserved_words.include?(slug) 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /bench.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../test/helper", __FILE__) 2 | require "ffaker" 3 | 4 | N = 10000 5 | 6 | def transaction 7 | ActiveRecord::Base.transaction { yield ; raise ActiveRecord::Rollback } 8 | end 9 | 10 | class Array 11 | def rand 12 | self[Kernel.rand(length)] 13 | end 14 | end 15 | 16 | Book = Class.new ActiveRecord::Base 17 | 18 | class Journalist < ActiveRecord::Base 19 | extend FriendlyId 20 | friendly_id :name, :use => :slugged 21 | end 22 | 23 | class Manual < ActiveRecord::Base 24 | extend FriendlyId 25 | friendly_id :name, :use => :history 26 | end 27 | 28 | class Restaurant < ActiveRecord::Base 29 | extend FriendlyId 30 | friendly_id :name, :use => :finders 31 | end 32 | 33 | 34 | BOOKS = [] 35 | JOURNALISTS = [] 36 | MANUALS = [] 37 | RESTAURANTS = [] 38 | 39 | 100.times do 40 | name = FFaker::Name.name 41 | BOOKS << (Book.create! :name => name).id 42 | JOURNALISTS << (Journalist.create! :name => name).friendly_id 43 | MANUALS << (Manual.create! :name => name).friendly_id 44 | RESTAURANTS << (Restaurant.create! :name => name).friendly_id 45 | end 46 | 47 | ActiveRecord::Base.connection.execute "UPDATE manuals SET slug = NULL" 48 | 49 | Benchmark.bmbm do |x| 50 | x.report 'find (without FriendlyId)' do 51 | N.times {Book.find BOOKS.rand} 52 | end 53 | 54 | x.report 'find (in-table slug)' do 55 | N.times {Journalist.friendly.find JOURNALISTS.rand} 56 | end 57 | 58 | x.report 'find (in-table slug; using finders module)' do 59 | N.times {Restaurant.find RESTAURANTS.rand} 60 | end 61 | 62 | x.report 'find (external slug)' do 63 | N.times {Manual.friendly.find MANUALS.rand} 64 | end 65 | 66 | x.report 'insert (without FriendlyId)' do 67 | N.times {transaction {Book.create :name => FFaker::Name.name}} 68 | end 69 | 70 | x.report 'insert (in-table-slug)' do 71 | N.times {transaction {Journalist.create :name => FFaker::Name.name}} 72 | end 73 | 74 | x.report 'insert (in-table-slug; using finders module)' do 75 | N.times {transaction {Restaurant.create :name => FFaker::Name.name}} 76 | end 77 | 78 | x.report 'insert (external slug)' do 79 | N.times {transaction {Manual.create :name => FFaker::Name.name}} 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /test/base_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | class CoreTest < TestCaseClass 4 | include FriendlyId::Test 5 | 6 | test "friendly_id can be added using 'extend'" do 7 | klass = Class.new(ActiveRecord::Base) do 8 | extend FriendlyId 9 | end 10 | assert klass.respond_to? :friendly_id 11 | end 12 | 13 | test "friendly_id can be added using 'include'" do 14 | klass = Class.new(ActiveRecord::Base) do 15 | include FriendlyId 16 | end 17 | assert klass.respond_to? :friendly_id 18 | end 19 | 20 | test "friendly_id should accept a base and a hash" do 21 | klass = Class.new(ActiveRecord::Base) do 22 | self.abstract_class = true 23 | extend FriendlyId 24 | friendly_id :foo, :use => :slugged, :slug_column => :bar 25 | end 26 | assert klass < FriendlyId::Slugged 27 | assert_equal :foo, klass.friendly_id_config.base 28 | assert_equal :bar, klass.friendly_id_config.slug_column 29 | end 30 | 31 | 32 | test "friendly_id should accept a block" do 33 | klass = Class.new(ActiveRecord::Base) do 34 | self.abstract_class = true 35 | extend FriendlyId 36 | friendly_id :foo do |config| 37 | config.use :slugged 38 | config.base = :foo 39 | config.slug_column = :bar 40 | end 41 | end 42 | assert klass < FriendlyId::Slugged 43 | assert_equal :foo, klass.friendly_id_config.base 44 | assert_equal :bar, klass.friendly_id_config.slug_column 45 | end 46 | 47 | test "the block passed to friendly_id should be evaluated before arguments" do 48 | klass = Class.new(ActiveRecord::Base) do 49 | self.abstract_class = true 50 | extend FriendlyId 51 | friendly_id :foo do |config| 52 | config.base = :bar 53 | end 54 | end 55 | assert_equal :foo, klass.friendly_id_config.base 56 | end 57 | 58 | test "should allow defaults to be set via a block" do 59 | begin 60 | FriendlyId.defaults do |config| 61 | config.base = :foo 62 | end 63 | klass = Class.new(ActiveRecord::Base) do 64 | self.abstract_class = true 65 | extend FriendlyId 66 | end 67 | assert_equal :foo, klass.friendly_id_config.base 68 | ensure 69 | FriendlyId.instance_variable_set :@defaults, nil 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /test/reserved_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | class ReservedTest < TestCaseClass 4 | 5 | include FriendlyId::Test 6 | 7 | class Journalist < ActiveRecord::Base 8 | extend FriendlyId 9 | friendly_id :slug_candidates, :use => [:slugged, :reserved], :reserved_words => %w(new edit) 10 | 11 | after_validation :move_friendly_id_error_to_name 12 | 13 | def move_friendly_id_error_to_name 14 | errors.add :name, *errors.delete(:friendly_id) if errors[:friendly_id].present? 15 | end 16 | 17 | def slug_candidates 18 | name 19 | end 20 | end 21 | 22 | def model_class 23 | Journalist 24 | end 25 | 26 | test "should reserve words" do 27 | %w(new edit NEW Edit).each do |word| 28 | transaction do 29 | assert_raises(ActiveRecord::RecordInvalid) {model_class.create! :name => word} 30 | end 31 | end 32 | end 33 | 34 | test "should move friendly_id error to name" do 35 | with_instance_of(model_class) do |record| 36 | record.errors.add :name, "xxx" 37 | record.errors.add :friendly_id, "yyy" 38 | record.move_friendly_id_error_to_name 39 | assert record.errors[:name].present? && record.errors[:friendly_id].blank? 40 | assert_equal 2, record.errors.count 41 | end 42 | end 43 | 44 | test "should reject reserved candidates" do 45 | transaction do 46 | record = model_class.new(:name => 'new') 47 | def record.slug_candidates 48 | [:name, "foo"] 49 | end 50 | record.save! 51 | assert_equal "foo", record.friendly_id 52 | end 53 | end 54 | 55 | test "should be invalid if all candidates are reserved" do 56 | transaction do 57 | record = model_class.new(:name => 'new') 58 | def record.slug_candidates 59 | ["edit", "new"] 60 | end 61 | assert_raises(ActiveRecord::RecordInvalid) {record.save!} 62 | end 63 | end 64 | 65 | test "should optionally treat reserved words as conflict" do 66 | klass = Class.new(model_class) do 67 | friendly_id :slug_candidates, :use => [:slugged, :reserved], :reserved_words => %w(new edit), :treat_reserved_as_conflict => true 68 | end 69 | 70 | with_instance_of(klass, name: 'new') do |record| 71 | assert_match(/new-([0-9a-z]+\-){4}[0-9a-z]+\z/, record.slug) 72 | end 73 | end 74 | 75 | end 76 | -------------------------------------------------------------------------------- /lib/friendly_id/finder_methods.rb: -------------------------------------------------------------------------------- 1 | module FriendlyId 2 | 3 | module FinderMethods 4 | 5 | # Finds a record using the given id. 6 | # 7 | # If the id is "unfriendly", it will call the original find method. 8 | # If the id is a numeric string like '123' it will first look for a friendly 9 | # id matching '123' and then fall back to looking for a record with the 10 | # numeric id '123'. 11 | # 12 | # Since FriendlyId 5.0, if the id is a nonnumeric string like '123-foo' it 13 | # will *only* search by friendly id and not fall back to the regular find 14 | # method. 15 | # 16 | # If you want to search only by the friendly id, use {#find_by_friendly_id}. 17 | # @raise ActiveRecord::RecordNotFound 18 | def find(*args) 19 | id = args.first 20 | return super if args.count != 1 || id.unfriendly_id? 21 | first_by_friendly_id(id).tap {|result| return result unless result.nil?} 22 | return super if potential_primary_key?(id) 23 | raise_not_found_exception id 24 | 25 | end 26 | 27 | # Returns true if a record with the given id exists. 28 | def exists?(conditions = :none) 29 | return super if conditions.unfriendly_id? 30 | return true if exists_by_friendly_id?(conditions) 31 | super 32 | end 33 | 34 | # Finds exclusively by the friendly id, completely bypassing original 35 | # `find`. 36 | # @raise ActiveRecord::RecordNotFound 37 | def find_by_friendly_id(id) 38 | first_by_friendly_id(id) or raise raise_not_found_exception(id) 39 | end 40 | 41 | def exists_by_friendly_id?(id) 42 | where(friendly_id_config.query_field => id).exists? 43 | end 44 | 45 | private 46 | 47 | def potential_primary_key?(id) 48 | key_type = primary_key_type 49 | # Hook for "ActiveModel::Type::Integer" instance. 50 | key_type = key_type.type if key_type.respond_to?(:type) 51 | case key_type 52 | when :integer 53 | Integer(id, 10) rescue false 54 | when :uuid 55 | id.match(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/) 56 | else 57 | true 58 | end 59 | end 60 | 61 | def first_by_friendly_id(id) 62 | find_by(friendly_id_config.query_field => id.downcase) 63 | end 64 | 65 | def raise_not_found_exception(id) 66 | message = "can't find record with friendly id: #{id.inspect}" 67 | if ActiveRecord.version < Gem::Version.create('5.0') then 68 | raise ActiveRecord::RecordNotFound.new(message) 69 | else 70 | raise ActiveRecord::RecordNotFound.new(message, name, friendly_id_config.query_field, id) 71 | end 72 | end 73 | 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/friendly_id/object_utils.rb: -------------------------------------------------------------------------------- 1 | module FriendlyId 2 | # Instances of these classes will never be considered a friendly id. 3 | # @see FriendlyId::ObjectUtils#friendly_id 4 | UNFRIENDLY_CLASSES = [ 5 | Array, 6 | FalseClass, 7 | Hash, 8 | NilClass, 9 | Numeric, 10 | Symbol, 11 | TrueClass 12 | ] 13 | 14 | # Utility methods for determining whether any object is a friendly id. 15 | # 16 | # Monkey-patching Object is a somewhat extreme measure not to be taken lightly 17 | # by libraries, but in this case I decided to do it because to me, it feels 18 | # cleaner than adding a module method to {FriendlyId}. I've given the methods 19 | # names that unambigously refer to the library of their origin, which should 20 | # be sufficient to avoid conflicts with other libraries. 21 | module ObjectUtils 22 | 23 | # True if the id is definitely friendly, false if definitely unfriendly, 24 | # else nil. 25 | # 26 | # An object is considired "definitely unfriendly" if its class is or 27 | # inherits from ActiveRecord::Base, Array, Hash, NilClass, Numeric, or 28 | # Symbol. 29 | # 30 | # An object is considered "definitely friendly" if it responds to +to_i+, 31 | # and its value when cast to an integer and then back to a string is 32 | # different from its value when merely cast to a string: 33 | # 34 | # 123.friendly_id? #=> false 35 | # :id.friendly_id? #=> false 36 | # {:name => 'joe'}.friendly_id? #=> false 37 | # ['name = ?', 'joe'].friendly_id? #=> false 38 | # nil.friendly_id? #=> false 39 | # "123".friendly_id? #=> nil 40 | # "abc123".friendly_id? #=> true 41 | def friendly_id? 42 | true if respond_to?(:to_i) && to_i.to_s != to_s 43 | end 44 | 45 | # True if the id is definitely unfriendly, false if definitely friendly, 46 | # else nil. 47 | def unfriendly_id? 48 | val = friendly_id? ; !val unless val.nil? 49 | end 50 | end 51 | 52 | module UnfriendlyUtils 53 | def friendly_id? 54 | false 55 | end 56 | 57 | def unfriendly_id? 58 | true 59 | end 60 | end 61 | 62 | def self.mark_as_unfriendly(klass) 63 | klass.send(:include, FriendlyId::UnfriendlyUtils) 64 | end 65 | end 66 | 67 | Object.send :include, FriendlyId::ObjectUtils 68 | 69 | # Considered unfriendly if object is an instance of an unfriendly class or 70 | # one of its descendants. 71 | 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 77 | -------------------------------------------------------------------------------- /test/benchmarks/finders.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../helper", __FILE__) 2 | require "ffaker" 3 | 4 | # This benchmark tests ActiveRecord and FriendlyId methods for performing a find 5 | # 6 | # ActiveRecord: where.first 8.970000 0.040000 9.010000 ( 9.029544) 7 | # ActiveRecord: where.take 8.100000 0.030000 8.130000 ( 8.157024) 8 | # ActiveRecord: find 2.720000 0.010000 2.730000 ( 2.733527) 9 | # ActiveRecord: find_by(:id) 2.920000 0.000000 2.920000 ( 2.926318) 10 | # ActiveRecord: find_by(:slug) 2.650000 0.020000 2.670000 ( 2.662677) 11 | # FriendlyId: find (in-table slug w/ finders) 9.820000 0.030000 9.850000 ( 9.873358) 12 | # FriendlyId: friendly.find (in-table slug) 12.890000 0.050000 12.940000 ( 12.951156) 13 | 14 | N = 50000 15 | 16 | def transaction 17 | ActiveRecord::Base.transaction { yield ; raise ActiveRecord::Rollback } 18 | end 19 | 20 | class Array 21 | def rand 22 | self[Kernel.rand(length)] 23 | end 24 | end 25 | 26 | Book = Class.new ActiveRecord::Base 27 | 28 | class Journalist < ActiveRecord::Base 29 | extend FriendlyId 30 | friendly_id :name, :use => :slugged 31 | end 32 | 33 | class Manual < ActiveRecord::Base 34 | extend FriendlyId 35 | friendly_id :name, :use => :history 36 | end 37 | 38 | class Restaurant < ActiveRecord::Base 39 | extend FriendlyId 40 | friendly_id :name, :use => :finders 41 | end 42 | 43 | 44 | BOOKS = [] 45 | JOURNALISTS = [] 46 | MANUALS = [] 47 | RESTAURANTS = [] 48 | 49 | 100.times do 50 | name = FFaker::Name.name 51 | BOOKS << (Book.create! :name => name).id 52 | JOURNALISTS << (Journalist.create! :name => name).friendly_id 53 | MANUALS << (Manual.create! :name => name).friendly_id 54 | RESTAURANTS << (Restaurant.create! :name => name).friendly_id 55 | end 56 | 57 | ActiveRecord::Base.connection.execute "UPDATE manuals SET slug = NULL" 58 | 59 | Benchmark.bmbm do |x| 60 | x.report 'ActiveRecord: where.first' do 61 | N.times {Book.where(:id=>BOOKS.rand).first} 62 | end 63 | 64 | x.report 'ActiveRecord: where.take' do 65 | N.times {Book.where(:id=>BOOKS.rand).take} 66 | end 67 | 68 | x.report 'ActiveRecord: find' do 69 | N.times {Book.find BOOKS.rand} 70 | end 71 | 72 | x.report 'ActiveRecord: find_by(:id)' do 73 | N.times {Book.find_by(:id=>BOOKS.rand)} 74 | end 75 | 76 | x.report 'ActiveRecord: find_by(:slug)' do 77 | N.times {Restaurant.find_by(:slug=>RESTAURANTS.rand)} 78 | end 79 | 80 | x.report 'FriendlyId: find (in-table slug w/ finders)' do 81 | N.times {Restaurant.find RESTAURANTS.rand} 82 | end 83 | 84 | x.report 'FriendlyId: friendly.find (in-table slug)' do 85 | N.times {Restaurant.friendly.find RESTAURANTS.rand} 86 | end 87 | 88 | end 89 | -------------------------------------------------------------------------------- /test/benchmarks/object_utils.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../helper", __FILE__) 2 | 3 | # This benchmark compares the timings of the friendly_id? and unfriendly_id? on various objects 4 | # 5 | # integer friendly_id? 6.370000 0.000000 6.370000 ( 6.380925) 6 | # integer unfriendly_id? 6.640000 0.010000 6.650000 ( 6.646057) 7 | # AR::Base friendly_id? 2.340000 0.000000 2.340000 ( 2.340743) 8 | # AR::Base unfriendly_id? 2.560000 0.000000 2.560000 ( 2.560039) 9 | # hash friendly_id? 5.090000 0.010000 5.100000 ( 5.097662) 10 | # hash unfriendly_id? 5.430000 0.000000 5.430000 ( 5.437160) 11 | # nil friendly_id? 5.610000 0.010000 5.620000 ( 5.611487) 12 | # nil unfriendly_id? 5.870000 0.000000 5.870000 ( 5.880484) 13 | # numeric string friendly_id? 9.270000 0.030000 9.300000 ( 9.308452) 14 | # numeric string unfriendly_id? 9.190000 0.040000 9.230000 ( 9.252890) 15 | # test_string friendly_id? 8.380000 0.010000 8.390000 ( 8.411762) 16 | # test_string unfriendly_id? 8.450000 0.010000 8.460000 ( 8.463662) 17 | 18 | # From the ObjectUtils docs... 19 | # 123.friendly_id? #=> false 20 | # :id.friendly_id? #=> false 21 | # {:name => 'joe'}.friendly_id? #=> false 22 | # ['name = ?', 'joe'].friendly_id? #=> false 23 | # nil.friendly_id? #=> false 24 | # "123".friendly_id? #=> nil 25 | # "abc123".friendly_id? #=> true 26 | 27 | Book = Class.new ActiveRecord::Base 28 | 29 | test_integer = 123 30 | test_active_record_object = Book.new 31 | test_hash = {:name=>'joe'} 32 | test_nil = nil 33 | test_numeric_string = "123" 34 | test_string = "abc123" 35 | 36 | N = 5_000_000 37 | 38 | Benchmark.bmbm do |x| 39 | x.report('integer friendly_id?') { N.times {test_integer.friendly_id?} } 40 | x.report('integer unfriendly_id?') { N.times {test_integer.unfriendly_id?} } 41 | 42 | x.report('AR::Base friendly_id?') { N.times {test_active_record_object.friendly_id?} } 43 | x.report('AR::Base unfriendly_id?') { N.times {test_active_record_object.unfriendly_id?} } 44 | 45 | x.report('hash friendly_id?') { N.times {test_hash.friendly_id?} } 46 | x.report('hash unfriendly_id?') { N.times {test_hash.unfriendly_id?} } 47 | 48 | x.report('nil friendly_id?') { N.times {test_nil.friendly_id?} } 49 | x.report('nil unfriendly_id?') { N.times {test_nil.unfriendly_id?} } 50 | 51 | x.report('numeric string friendly_id?') { N.times {test_numeric_string.friendly_id?} } 52 | x.report('numeric string unfriendly_id?') { N.times {test_numeric_string.unfriendly_id?} } 53 | 54 | x.report('test_string friendly_id?') { N.times {test_string.friendly_id?} } 55 | x.report('test_string unfriendly_id?') { N.times {test_string.unfriendly_id?} } 56 | end 57 | -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | 3 | if ENV['COVERALLS'] || ENV['COVERAGE'] 4 | require 'simplecov' 5 | if ENV['COVERALLS'] 6 | require 'coveralls' 7 | SimpleCov.formatter = Coveralls::SimpleCov::Formatter 8 | end 9 | SimpleCov.start do 10 | add_filter 'test' 11 | add_filter 'friendly_id/migration' 12 | end 13 | end 14 | 15 | begin 16 | require 'minitest' 17 | rescue LoadError 18 | require 'minitest/unit' 19 | end 20 | 21 | begin 22 | TestCaseClass = MiniTest::Test 23 | rescue NameError 24 | TestCaseClass = MiniTest::Unit::TestCase 25 | end 26 | 27 | require "mocha/setup" 28 | require "active_record" 29 | require 'active_support/core_ext/time/conversions' 30 | require 'erb' 31 | 32 | I18n.enforce_available_locales = false 33 | 34 | require "friendly_id" 35 | 36 | # If you want to see the ActiveRecord log, invoke the tests using `rake test LOG=true` 37 | if ENV["LOG"] 38 | require "logger" 39 | ActiveRecord::Base.logger = Logger.new($stdout) 40 | end 41 | 42 | if ActiveSupport::VERSION::STRING >= '4.2' 43 | ActiveSupport.test_order = :random 44 | end 45 | 46 | module FriendlyId 47 | module Test 48 | 49 | def self.included(base) 50 | if Minitest.respond_to?(:autorun) 51 | Minitest.autorun 52 | else 53 | require 'minitest/autorun' 54 | end 55 | rescue LoadError 56 | end 57 | 58 | def transaction 59 | ActiveRecord::Base.transaction { yield ; raise ActiveRecord::Rollback } 60 | end 61 | 62 | def with_instance_of(*args) 63 | model_class = args.shift 64 | args[0] ||= {:name => "a b c"} 65 | transaction { yield model_class.create!(*args) } 66 | end 67 | 68 | module Database 69 | extend self 70 | 71 | def connect 72 | version = ActiveRecord::VERSION::STRING 73 | engine = RUBY_ENGINE rescue "ruby" 74 | 75 | ActiveRecord::Base.establish_connection config[driver] 76 | message = "Using #{engine} #{RUBY_VERSION} AR #{version} with #{driver}" 77 | 78 | puts "-" * 72 79 | if in_memory? 80 | ActiveRecord::Migration.verbose = false 81 | Schema.migrate :up 82 | puts "#{message} (in-memory)" 83 | else 84 | puts message 85 | end 86 | end 87 | 88 | def config 89 | @config ||= YAML::load( 90 | ERB.new( 91 | File.read(File.expand_path("../databases.yml", __FILE__)) 92 | ).result 93 | ) 94 | end 95 | 96 | def driver 97 | _driver = ENV.fetch('DB', 'sqlite3').downcase 98 | _driver = "postgres" if %w(postgresql pg).include?(_driver) 99 | _driver 100 | end 101 | 102 | def in_memory? 103 | config[driver]["database"] == ":memory:" 104 | end 105 | end 106 | end 107 | end 108 | 109 | class Module 110 | def test(name, &block) 111 | define_method("test_#{name.gsub(/[^a-z0-9']/i, "_")}".to_sym, &block) 112 | end 113 | end 114 | 115 | require "schema" 116 | require "shared" 117 | FriendlyId::Test::Database.connect 118 | at_exit {ActiveRecord::Base.connection.disconnect!} 119 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "rake/testtask" 3 | 4 | task :default => :test 5 | 6 | task :load_path do 7 | %w(lib test).each do |path| 8 | $LOAD_PATH.unshift(File.expand_path("../#{path}", __FILE__)) 9 | end 10 | end 11 | 12 | Rake::TestTask.new do |t| 13 | t.libs << "test" 14 | t.test_files = FileList['test/*_test.rb'] 15 | t.verbose = true 16 | end 17 | 18 | desc "Remove temporary files" 19 | task :clean do 20 | %x{rm -rf *.gem doc pkg coverage} 21 | %x{rm -f `find . -name '*.rbc'`} 22 | end 23 | 24 | desc "Build the gem" 25 | task :gem do 26 | %x{gem build friendly_id.gemspec} 27 | end 28 | 29 | desc "Build YARD documentation" 30 | task :yard do 31 | puts %x{bundle exec yard} 32 | end 33 | 34 | desc "Run benchmarks" 35 | task :bench => :load_path do 36 | require File.expand_path("../bench", __FILE__) 37 | end 38 | 39 | desc "Run benchmarks on finders" 40 | task :bench_finders => :load_path do 41 | require File.expand_path("../test/benchmarks/finders", __FILE__) 42 | end 43 | 44 | desc "Run benchmarks on ObjectUtils" 45 | task :bench_object_utils => :load_path do 46 | require File.expand_path("../test/benchmarks/object_utils", __FILE__) 47 | end 48 | 49 | desc "Generate Guide.md" 50 | task :guide do 51 | load File.expand_path('../guide.rb', __FILE__) 52 | end 53 | 54 | namespace :test do 55 | 56 | desc "Run each test class in a separate process" 57 | task :isolated do 58 | dir = File.expand_path("../test", __FILE__) 59 | Dir["#{dir}/*_test.rb"].each do |test| 60 | puts "Running #{test}:" 61 | puts %x{ruby -Ilib -Itest #{test}} 62 | end 63 | end 64 | end 65 | 66 | namespace :db do 67 | 68 | desc "Create the database" 69 | task :create => :load_path do 70 | require "helper" 71 | driver = FriendlyId::Test::Database.driver 72 | config = FriendlyId::Test::Database.config[driver] 73 | commands = { 74 | "mysql" => "mysql -u #{config['username']} --password=#{config['password']} -e 'create database #{config["database"]};' >/dev/null", 75 | "postgres" => "psql -c 'create database #{config['database']};' -U #{config['username']} >/dev/null" 76 | } 77 | %x{#{commands[driver] || true}} 78 | end 79 | 80 | desc "Drop the database" 81 | task :drop => :load_path do 82 | require "helper" 83 | driver = FriendlyId::Test::Database.driver 84 | config = FriendlyId::Test::Database.config[driver] 85 | commands = { 86 | "mysql" => "mysql -u #{config['username']} --password=#{config['password']} -e 'drop database #{config["database"]};' >/dev/null", 87 | "postgres" => "psql -c 'drop database #{config['database']};' -U #{config['username']} >/dev/null" 88 | } 89 | %x{#{commands[driver] || true}} 90 | end 91 | 92 | desc "Set up the database schema" 93 | task :up => :load_path do 94 | require "helper" 95 | FriendlyId::Test::Schema.up 96 | end 97 | 98 | desc "Drop and recreate the database schema" 99 | task :reset => [:drop, :create] 100 | 101 | end 102 | 103 | task :doc => :yard 104 | 105 | task :docs do 106 | sh %{git checkout gh-pages && rake doc && git checkout @{-1}} 107 | end 108 | -------------------------------------------------------------------------------- /lib/friendly_id/sequentially_slugged.rb: -------------------------------------------------------------------------------- 1 | module FriendlyId 2 | module SequentiallySlugged 3 | def self.setup(model_class) 4 | model_class.friendly_id_config.use :slugged 5 | end 6 | 7 | def resolve_friendly_id_conflict(candidate_slugs) 8 | candidate = candidate_slugs.first 9 | 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 | 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 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 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/friendly_id/simple_i18n.rb: -------------------------------------------------------------------------------- 1 | require "i18n" 2 | 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 71 | module SimpleI18n 72 | 73 | # FriendlyId::Config.use will invoke this method when present, to allow 74 | # loading dependent modules prior to overriding them when necessary. 75 | def self.setup(model_class) 76 | model_class.friendly_id_config.use :slugged 77 | end 78 | 79 | def self.included(model_class) 80 | model_class.class_eval do 81 | friendly_id_config.class.send :include, Configuration 82 | include Model 83 | end 84 | end 85 | 86 | module Model 87 | def set_friendly_id(text, locale = nil) 88 | I18n.with_locale(locale || I18n.locale) do 89 | set_slug(normalize_friendly_id(text)) 90 | end 91 | end 92 | 93 | def slug=(value) 94 | super 95 | write_attribute friendly_id_config.slug_column, value 96 | end 97 | end 98 | 99 | module Configuration 100 | def slug_column 101 | "#{super}_#{I18n.locale}" 102 | end 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /test/scoped_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | class Novelist < ActiveRecord::Base 4 | extend FriendlyId 5 | friendly_id :name, :use => :slugged 6 | end 7 | 8 | class Novel < ActiveRecord::Base 9 | extend FriendlyId 10 | belongs_to :novelist 11 | belongs_to :publisher 12 | friendly_id :name, :use => :scoped, :scope => [:publisher, :novelist] 13 | 14 | def should_generate_new_friendly_id? 15 | new_record? || super 16 | end 17 | end 18 | 19 | class Publisher < ActiveRecord::Base 20 | has_many :novels 21 | end 22 | 23 | class ScopedTest < TestCaseClass 24 | 25 | include FriendlyId::Test 26 | include FriendlyId::Test::Shared::Core 27 | 28 | def model_class 29 | Novel 30 | end 31 | 32 | test "should detect scope column from belongs_to relation" do 33 | assert_equal ["publisher_id", "novelist_id"], Novel.friendly_id_config.scope_columns 34 | end 35 | 36 | test "should detect scope column from explicit column name" do 37 | model_class = Class.new(ActiveRecord::Base) do 38 | self.abstract_class = true 39 | extend FriendlyId 40 | friendly_id :empty, :use => :scoped, :scope => :dummy 41 | end 42 | assert_equal ["dummy"], model_class.friendly_id_config.scope_columns 43 | end 44 | 45 | test "should allow duplicate slugs outside scope" do 46 | transaction do 47 | novel1 = Novel.create! :name => "a", :novelist => Novelist.create!(:name => "a") 48 | novel2 = Novel.create! :name => "a", :novelist => Novelist.create!(:name => "b") 49 | assert_equal novel1.friendly_id, novel2.friendly_id 50 | end 51 | end 52 | 53 | test "should not allow duplicate slugs inside scope" do 54 | with_instance_of Novelist do |novelist| 55 | novel1 = Novel.create! :name => "a", :novelist => novelist 56 | novel2 = Novel.create! :name => "a", :novelist => novelist 57 | assert novel1.friendly_id != novel2.friendly_id 58 | end 59 | end 60 | 61 | test "should apply scope with multiple columns" do 62 | transaction do 63 | novelist = Novelist.create! :name => "a" 64 | publisher = Publisher.create! :name => "b" 65 | novel1 = Novel.create! :name => "c", :novelist => novelist, :publisher => publisher 66 | novel2 = Novel.create! :name => "c", :novelist => novelist, :publisher => Publisher.create(:name => "d") 67 | novel3 = Novel.create! :name => "c", :novelist => Novelist.create(:name => "e"), :publisher => publisher 68 | novel4 = Novel.create! :name => "c", :novelist => novelist, :publisher => publisher 69 | assert_equal novel1.friendly_id, novel2.friendly_id 70 | assert_equal novel2.friendly_id, novel3.friendly_id 71 | assert novel3.friendly_id != novel4.friendly_id 72 | end 73 | end 74 | 75 | test 'should allow a record to reuse its own slug' do 76 | with_instance_of(model_class) do |record| 77 | old_id = record.friendly_id 78 | record.slug = nil 79 | record.save! 80 | assert_equal old_id, record.friendly_id 81 | end 82 | end 83 | 84 | test "should generate new slug when scope changes" do 85 | transaction do 86 | novelist = Novelist.create! :name => "a" 87 | publisher = Publisher.create! :name => "b" 88 | novel1 = Novel.create! :name => "c", :novelist => novelist, :publisher => publisher 89 | novel2 = Novel.create! :name => "c", :novelist => novelist, :publisher => Publisher.create(:name => "d") 90 | assert_equal novel1.friendly_id, novel2.friendly_id 91 | novel2.publisher = publisher 92 | novel2.save! 93 | assert novel2.friendly_id != novel1.friendly_id 94 | end 95 | end 96 | 97 | end 98 | -------------------------------------------------------------------------------- /lib/friendly_id/finders.rb: -------------------------------------------------------------------------------- 1 | module FriendlyId 2 | =begin 3 | ## Performing Finds with FriendlyId 4 | 5 | FriendlyId offers enhanced finders which will search for your record by 6 | friendly id, and fall back to the numeric id if necessary. This makes it easy 7 | to add FriendlyId to an existing application with minimal code modification. 8 | 9 | By default, these methods are available only on the `friendly` scope: 10 | 11 | Restaurant.friendly.find('plaza-diner') #=> works 12 | Restaurant.friendly.find(23) #=> also works 13 | Restaurant.find(23) #=> still works 14 | Restaurant.find('plaza-diner') #=> will not work 15 | 16 | ### Restoring FriendlyId 4.0-style finders 17 | 18 | Prior to version 5.0, FriendlyId overrode the default finder methods to perform 19 | friendly finds all the time. This required modifying parts of Rails that did 20 | not have a public API, which was harder to maintain and at times caused 21 | compatiblity problems. In 5.0 we decided to change the library's defaults and add 22 | the friendly finder methods only to the `friendly` scope in order to boost 23 | compatiblity. However, you can still opt-in to original functionality very 24 | easily by using the `:finders` addon: 25 | 26 | class Restaurant < ActiveRecord::Base 27 | extend FriendlyId 28 | 29 | scope :active, -> {where(:active => true)} 30 | 31 | friendly_id :name, :use => [:slugged, :finders] 32 | end 33 | 34 | Restaurant.friendly.find('plaza-diner') #=> works 35 | Restaurant.find('plaza-diner') #=> now also works 36 | Restaurant.active.find('plaza-diner') #=> now also works 37 | 38 | ### Updating your application to use FriendlyId's finders 39 | 40 | Unless you've chosen to use the `:finders` addon, be sure to modify the finders 41 | in your controllers to use the `friendly` scope. For example: 42 | 43 | # before 44 | def set_restaurant 45 | @restaurant = Restaurant.find(params[:id]) 46 | end 47 | 48 | # after 49 | def set_restaurant 50 | @restaurant = Restaurant.friendly.find(params[:id]) 51 | end 52 | 53 | #### Active Admin 54 | 55 | Unless you use the `:finders` addon, you should modify your admin controllers 56 | for models that use FriendlyId with something similar to the following: 57 | 58 | controller do 59 | def find_resource 60 | scoped_collection.friendly.find(params[:id]) 61 | end 62 | end 63 | 64 | =end 65 | module Finders 66 | 67 | module ClassMethods 68 | if (ActiveRecord::VERSION::MAJOR == 4) && (ActiveRecord::VERSION::MINOR == 0) 69 | def relation_delegate_class(klass) 70 | relation_class_name = :"#{klass.to_s.gsub('::', '_')}_#{self.to_s.gsub('::', '_')}" 71 | klass.const_get(relation_class_name) 72 | end 73 | end 74 | end 75 | 76 | def self.setup(model_class) 77 | model_class.instance_eval do 78 | relation.class.send(:include, friendly_id_config.finder_methods) 79 | if (ActiveRecord::VERSION::MAJOR == 4 && ActiveRecord::VERSION::MINOR == 2) || ActiveRecord::VERSION::MAJOR >= 5 80 | model_class.send(:extend, friendly_id_config.finder_methods) 81 | end 82 | end 83 | 84 | # Support for friendly finds on associations for Rails 4.0.1 and above. 85 | if ::ActiveRecord.const_defined?('AssociationRelation') 86 | model_class.extend(ClassMethods) 87 | association_relation_delegate_class = model_class.relation_delegate_class(::ActiveRecord::AssociationRelation) 88 | association_relation_delegate_class.send(:include, model_class.friendly_id_config.finder_methods) 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /test/schema.rb: -------------------------------------------------------------------------------- 1 | require "friendly_id/migration" 2 | 3 | module FriendlyId 4 | module Test 5 | migration_class = 6 | if ActiveRecord::VERSION::MAJOR >= 5 7 | ActiveRecord::Migration[4.2] 8 | else 9 | ActiveRecord::Migration 10 | end 11 | 12 | class Schema < migration_class 13 | class << self 14 | def down 15 | CreateFriendlyIdSlugs.down 16 | tables.each do |name| 17 | drop_table name 18 | end 19 | end 20 | 21 | def up 22 | # TODO: use schema version to avoid ugly hacks like this 23 | return if @done 24 | CreateFriendlyIdSlugs.migrate :up 25 | 26 | tables.each do |table_name| 27 | create_table table_name do |t| 28 | t.string :name 29 | t.boolean :active 30 | end 31 | end 32 | 33 | tables_with_uuid_primary_key.each do |table_name| 34 | create_table table_name, primary_key: :uuid_key, id: false do |t| 35 | t.string :name 36 | t.string :uuid_key, null: false 37 | t.string :slug 38 | end 39 | add_index table_name, :slug, unique: true 40 | end 41 | 42 | slugged_tables.each do |table_name| 43 | add_column table_name, :slug, :string 44 | add_index table_name, :slug, :unique => true if 'novels' != table_name 45 | end 46 | 47 | scoped_tables.each do |table_name| 48 | add_column table_name, :slug, :string 49 | end 50 | 51 | paranoid_tables.each do |table_name| 52 | add_column table_name, :slug, :string 53 | add_column table_name, :deleted_at, :datetime 54 | add_index table_name, :deleted_at 55 | end 56 | 57 | # This will be used to test scopes 58 | add_column :novels, :novelist_id, :integer 59 | add_column :novels, :publisher_id, :integer 60 | add_index :novels, [:slug, :publisher_id, :novelist_id], :unique => true 61 | 62 | # This will be used to test column name quoting 63 | add_column :journalists, "strange name", :string 64 | 65 | # This will be used to test STI 66 | add_column :journalists, "type", :string 67 | 68 | # These will be used to test i18n 69 | add_column :journalists, "slug_en", :string 70 | add_column :journalists, "slug_es", :string 71 | add_column :journalists, "slug_de", :string 72 | 73 | # This will be used to test relationships 74 | add_column :books, :author_id, :integer 75 | 76 | # Used to test :scoped and :history together 77 | add_column :restaurants, :city_id, :integer 78 | 79 | # Used to test candidates 80 | add_column :cities, :code, :string, :limit => 3 81 | 82 | # Used as a non-default slug_column 83 | add_column :authors, :subdomain, :string 84 | 85 | @done = true 86 | end 87 | 88 | private 89 | 90 | def slugged_tables 91 | %w[journalists articles novelists novels manuals cities] 92 | end 93 | 94 | def paranoid_tables 95 | ["paranoid_records"] 96 | end 97 | 98 | def tables_with_uuid_primary_key 99 | ["menu_items"] 100 | end 101 | 102 | def scoped_tables 103 | ["restaurants"] 104 | end 105 | 106 | def simple_tables 107 | %w[authors books publishers] 108 | end 109 | 110 | def tables 111 | simple_tables + slugged_tables + scoped_tables + paranoid_tables 112 | end 113 | end 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/friendly_id/configuration.rb: -------------------------------------------------------------------------------- 1 | module FriendlyId 2 | # The configuration parameters passed to {Base#friendly_id} will be stored in 3 | # this object. 4 | class Configuration 5 | 6 | attr_writer :base 7 | 8 | # The default configuration options. 9 | attr_reader :defaults 10 | 11 | # The modules in use 12 | attr_reader :modules 13 | 14 | # The model class that this configuration belongs to. 15 | # @return ActiveRecord::Base 16 | attr_accessor :model_class 17 | 18 | # The module to use for finders 19 | attr_accessor :finder_methods 20 | 21 | # The value used for the slugged association's dependent option 22 | attr_accessor :dependent 23 | 24 | # Route generation preferences 25 | attr_accessor :routes 26 | 27 | def initialize(model_class, values = nil) 28 | @base = nil 29 | @model_class = model_class 30 | @defaults = {} 31 | @modules = [] 32 | @finder_methods = FriendlyId::FinderMethods 33 | self.routes = :friendly 34 | set values 35 | end 36 | 37 | # Lets you specify the addon modules to use with FriendlyId. 38 | # 39 | # This method is invoked by {FriendlyId::Base#friendly_id friendly_id} when 40 | # passing the `:use` option, or when using {FriendlyId::Base#friendly_id 41 | # friendly_id} with a block. 42 | # 43 | # @example 44 | # class Book < ActiveRecord::Base 45 | # extend FriendlyId 46 | # friendly_id :name, :use => :slugged 47 | # end 48 | # 49 | # @param [#to_s,Module] modules Arguments should be Modules, or symbols or 50 | # strings that correspond with the name of an addon to use with FriendlyId. 51 | # By default FriendlyId provides `:slugged`, `:finders`, `:history`, 52 | # `:reserved`, `:simple_i18n`, and `:scoped`. 53 | def use(*modules) 54 | modules.to_a.flatten.compact.map do |object| 55 | mod = get_module(object) 56 | mod.setup(@model_class) if mod.respond_to?(:setup) 57 | @model_class.send(:include, mod) unless uses? object 58 | end 59 | end 60 | 61 | # Returns whether the given module is in use. 62 | def uses?(mod) 63 | @model_class < get_module(mod) 64 | end 65 | 66 | # The column that FriendlyId will use to find the record when querying by 67 | # friendly id. 68 | # 69 | # This method is generally only used internally by FriendlyId. 70 | # @return String 71 | def query_field 72 | base.to_s 73 | end 74 | 75 | # The base column or method used by FriendlyId as the basis of a friendly id 76 | # or slug. 77 | # 78 | # For models that don't use {FriendlyId::Slugged}, this is the column that 79 | # is used to store the friendly id. For models using {FriendlyId::Slugged}, 80 | # the base is a column or method whose value is used as the basis of the 81 | # slug. 82 | # 83 | # For example, if you have a model representing blog posts and that uses 84 | # slugs, you likely will want to use the "title" attribute as the base, and 85 | # FriendlyId will take care of transforming the human-readable title into 86 | # something suitable for use in a URL. 87 | # 88 | # If you pass an argument, it will be used as the base. Otherwise the current 89 | # value is returned. 90 | # 91 | # @param value A symbol referencing a column or method in the model. This 92 | # value is usually set by passing it as the first argument to 93 | # {FriendlyId::Base#friendly_id friendly_id}. 94 | def base(*value) 95 | if value.empty? 96 | @base 97 | else 98 | self.base = value.first 99 | end 100 | end 101 | 102 | private 103 | 104 | def get_module(object) 105 | Module === object ? object : FriendlyId.const_get(object.to_s.titleize.camelize.gsub(/\s+/, '')) 106 | end 107 | 108 | def set(values) 109 | values and values.each {|name, value| self.send "#{name}=", value} 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /test/sti_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | class StiTest < TestCaseClass 4 | 5 | include FriendlyId::Test 6 | include FriendlyId::Test::Shared::Core 7 | include FriendlyId::Test::Shared::Slugged 8 | 9 | class Journalist < ActiveRecord::Base 10 | extend FriendlyId 11 | friendly_id :name, :use => [:slugged] 12 | end 13 | 14 | class Editorialist < Journalist 15 | end 16 | 17 | def model_class 18 | Editorialist 19 | end 20 | 21 | test "friendly_id should accept a base and a hash with single table inheritance" do 22 | abstract_klass = Class.new(ActiveRecord::Base) do 23 | def self.table_exists?; false end 24 | extend FriendlyId 25 | friendly_id :foo, :use => :slugged, :slug_column => :bar 26 | end 27 | klass = Class.new(abstract_klass) 28 | assert klass < FriendlyId::Slugged 29 | assert_equal :foo, klass.friendly_id_config.base 30 | assert_equal :bar, klass.friendly_id_config.slug_column 31 | end 32 | 33 | test "the configuration's model_class should be the class, not the base_class" do 34 | assert_equal model_class, model_class.friendly_id_config.model_class 35 | end 36 | 37 | test "friendly_id should accept a block with single table inheritance" do 38 | abstract_klass = Class.new(ActiveRecord::Base) do 39 | def self.table_exists?; false end 40 | extend FriendlyId 41 | friendly_id :foo do |config| 42 | config.use :slugged 43 | config.base = :foo 44 | config.slug_column = :bar 45 | end 46 | end 47 | klass = Class.new(abstract_klass) 48 | assert klass < FriendlyId::Slugged 49 | assert_equal :foo, klass.friendly_id_config.base 50 | assert_equal :bar, klass.friendly_id_config.slug_column 51 | end 52 | 53 | test "friendly_id slugs should not clash with each other" do 54 | transaction do 55 | journalist = model_class.base_class.create! :name => 'foo bar' 56 | editoralist = model_class.create! :name => 'foo bar' 57 | 58 | assert_equal 'foo-bar', journalist.slug 59 | assert_match(/foo-bar-.+/, editoralist.slug) 60 | end 61 | end 62 | end 63 | 64 | class StiTestWithHistory < StiTest 65 | class Journalist < ActiveRecord::Base 66 | extend FriendlyId 67 | friendly_id :name, :use => [:slugged, :history] 68 | end 69 | 70 | class Editorialist < Journalist 71 | end 72 | 73 | def model_class 74 | Editorialist 75 | end 76 | end 77 | 78 | 79 | class StiTestWithFinders < TestCaseClass 80 | 81 | include FriendlyId::Test 82 | 83 | class Journalist < ActiveRecord::Base 84 | extend FriendlyId 85 | friendly_id :name, :use => [:slugged, :finders] 86 | end 87 | 88 | class Editorialist < Journalist 89 | extend FriendlyId 90 | friendly_id :name, :use => [:slugged, :finders] 91 | end 92 | 93 | def model_class 94 | Editorialist 95 | end 96 | 97 | test "friendly_id slugs should be looked up from subclass with friendly" do 98 | transaction do 99 | editoralist = model_class.create! :name => 'foo bar' 100 | assert_equal editoralist, model_class.friendly.find(editoralist.slug) 101 | end 102 | end 103 | 104 | test "friendly_id slugs should be looked up from subclass" do 105 | transaction do 106 | editoralist = model_class.create! :name => 'foo bar' 107 | assert_equal editoralist, model_class.find(editoralist.slug) 108 | end 109 | end 110 | 111 | end 112 | 113 | class StiTestSubClass < TestCaseClass 114 | 115 | include FriendlyId::Test 116 | 117 | class Journalist < ActiveRecord::Base 118 | extend FriendlyId 119 | end 120 | 121 | class Editorialist < Journalist 122 | extend FriendlyId 123 | friendly_id :name, :use => [:slugged, :finders] 124 | end 125 | 126 | def model_class 127 | Editorialist 128 | end 129 | 130 | test "friendly_id slugs can be created and looked up from subclass" do 131 | transaction do 132 | editoralist = model_class.create! :name => 'foo bar' 133 | assert_equal editoralist, model_class.find(editoralist.slug) 134 | end 135 | end 136 | 137 | end -------------------------------------------------------------------------------- /test/candidates_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | class CandidatesTest < TestCaseClass 4 | 5 | include FriendlyId::Test 6 | 7 | class City < ActiveRecord::Base 8 | extend FriendlyId 9 | friendly_id :slug_candidates, use: :slugged 10 | alias_attribute :slug_candidates, :name 11 | end 12 | 13 | def model_class 14 | City 15 | end 16 | 17 | def with_instances_of(klass = model_class, &block) 18 | transaction do 19 | city1 = klass.create! :name => "New York", :code => "JFK" 20 | city2 = klass.create! :name => "New York", :code => "EWR" 21 | yield city1, city2 22 | end 23 | end 24 | alias_method :with_instances, :with_instances_of 25 | 26 | test "resolves conflict with candidate" do 27 | with_instances do |city1, city2| 28 | assert_equal "new-york", city1.slug 29 | assert_match(/\Anew-york-([a-z0-9]+\-){4}[a-z0-9]+\z/, city2.slug) 30 | end 31 | end 32 | 33 | test "accepts candidate as symbol" do 34 | klass = Class.new model_class do 35 | def slug_candidates 36 | :name 37 | end 38 | end 39 | with_instances_of klass do |_, city| 40 | assert_match(/\Anew-york-([a-z0-9]+\-){4}[a-z0-9]+\z/, city.slug) 41 | end 42 | end 43 | 44 | test "accepts multiple candidates" do 45 | klass = Class.new model_class do 46 | def slug_candidates 47 | [name, code] 48 | end 49 | end 50 | with_instances_of klass do |_, city| 51 | assert_equal "ewr", city.slug 52 | end 53 | end 54 | 55 | test "ignores blank candidate" do 56 | klass = Class.new model_class do 57 | def slug_candidates 58 | [name, ""] 59 | end 60 | end 61 | with_instances_of klass do |_, city| 62 | assert_match(/\Anew-york-([a-z0-9]+\-){4}[a-z0-9]+\z/, city.slug) 63 | end 64 | end 65 | 66 | test "ignores nil candidate" do 67 | klass = Class.new model_class do 68 | def slug_candidates 69 | [name, nil] 70 | end 71 | end 72 | with_instances_of klass do |_, city| 73 | assert_match(/\Anew-york-([a-z0-9]+\-){4}[a-z0-9]+\z/, city.slug) 74 | end 75 | end 76 | 77 | test "accepts candidate with nested array" do 78 | klass = Class.new model_class do 79 | def slug_candidates 80 | [name, [name, code]] 81 | end 82 | end 83 | with_instances_of klass do |_, city| 84 | assert_equal "new-york-ewr", city.slug 85 | end 86 | end 87 | 88 | test "accepts candidate with lambda" do 89 | klass = Class.new City do 90 | def slug_candidates 91 | [name, [name, ->{ rand 1000 }]] 92 | end 93 | end 94 | with_instances_of klass do |_, city| 95 | assert_match(/\Anew-york-\d{,3}\z/, city.friendly_id) 96 | end 97 | end 98 | 99 | test "accepts candidate with object" do 100 | klass = Class.new City do 101 | class Airport 102 | def initialize(code) 103 | @code = code 104 | end 105 | attr_reader :code 106 | alias_method :to_s, :code 107 | end 108 | def slug_candidates 109 | [name, [name, Airport.new(code)]] 110 | end 111 | end 112 | with_instances_of klass do |_, city| 113 | assert_equal "new-york-ewr", city.friendly_id 114 | end 115 | end 116 | 117 | test "allows to iterate through candidates without passing block" do 118 | klass = Class.new model_class do 119 | def slug_candidates 120 | :name 121 | end 122 | end 123 | with_instances_of klass do |_, city| 124 | candidates = FriendlyId::Candidates.new(city, city.slug_candidates) 125 | assert_equal candidates.each, ['new-york'] 126 | end 127 | end 128 | 129 | test "iterates through candidates with passed block" do 130 | klass = Class.new model_class do 131 | def slug_candidates 132 | :name 133 | end 134 | end 135 | with_instances_of klass do |_, city| 136 | collected_candidates = [] 137 | candidates = FriendlyId::Candidates.new(city, city.slug_candidates) 138 | candidates.each { |candidate| collected_candidates << candidate } 139 | assert_equal collected_candidates, ['new-york'] 140 | end 141 | end 142 | 143 | end 144 | -------------------------------------------------------------------------------- /lib/friendly_id/initializer.rb: -------------------------------------------------------------------------------- 1 | # FriendlyId Global Configuration 2 | # 3 | # Use this to set up shared configuration options for your entire application. 4 | # Any of the configuration options shown here can also be applied to single 5 | # models by passing arguments to the `friendly_id` class method or defining 6 | # methods in your model. 7 | # 8 | # To learn more, check out the guide: 9 | # 10 | # http://norman.github.io/friendly_id/file.Guide.html 11 | 12 | FriendlyId.defaults do |config| 13 | # ## Reserved Words 14 | # 15 | # Some words could conflict with Rails's routes when used as slugs, or are 16 | # undesirable to allow as slugs. Edit this list as needed for your app. 17 | config.use :reserved 18 | 19 | config.reserved_words = %w(new edit index session login logout users admin 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 27 | 28 | # ## Friendly Finders 29 | # 30 | # Uncomment this to use friendly finders in all models. By default, if 31 | # you wish to find a record by its friendly id, you must do: 32 | # 33 | # MyModel.friendly.find('foo') 34 | # 35 | # If you uncomment this, you can do: 36 | # 37 | # MyModel.find('foo') 38 | # 39 | # This is significantly more convenient but may not be appropriate for 40 | # all applications, so you must explicity opt-in to this behavior. You can 41 | # always also configure it on a per-model basis if you prefer. 42 | # 43 | # Something else to consider is that using the :finders addon boosts 44 | # performance because it will avoid Rails-internal code that makes runtime 45 | # calls to `Module.extend`. 46 | # 47 | # config.use :finders 48 | # 49 | # ## Slugs 50 | # 51 | # Most applications will use the :slugged module everywhere. If you wish 52 | # to do so, uncomment the following line. 53 | # 54 | # config.use :slugged 55 | # 56 | # By default, FriendlyId's :slugged addon expects the slug column to be named 57 | # 'slug', but you can change it if you wish. 58 | # 59 | # config.slug_column = 'slug' 60 | # 61 | # By default, slug has no size limit, but you can change it if you wish. 62 | # 63 | # config.slug_limit = 255 64 | # 65 | # When FriendlyId can not generate a unique ID from your base method, it appends 66 | # a UUID, separated by a single dash. You can configure the character used as the 67 | # separator. If you're upgrading from FriendlyId 4, you may wish to replace this 68 | # with two dashes. 69 | # 70 | # config.sequence_separator = '-' 71 | # 72 | # Note that you must use the :slugged addon **prior** to the line which 73 | # configures the sequence separator, or else FriendlyId will raise an undefined 74 | # method error. 75 | # 76 | # ## Tips and Tricks 77 | # 78 | # ### Controlling when slugs are generated 79 | # 80 | # As of FriendlyId 5.0, new slugs are generated only when the slug field is 81 | # nil, but if you're using a column as your base method can change this 82 | # behavior by overriding the `should_generate_new_friendly_id?` method that 83 | # FriendlyId adds to your model. The change below makes FriendlyId 5.0 behave 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. 89 | # 90 | # config.use :slugged 91 | # config.use Module.new { 92 | # def should_generate_new_friendly_id? 93 | # slug.blank? || _changed? 94 | # end 95 | # } 96 | # 97 | # FriendlyId uses Rails's `parameterize` method to generate slugs, but for 98 | # languages that don't use the Roman alphabet, that's not usually sufficient. 99 | # Here we use the Babosa library to transliterate Russian Cyrillic slugs to 100 | # ASCII. If you use this, don't forget to add "babosa" to your Gemfile. 101 | # 102 | # config.use Module.new { 103 | # def normalize_friendly_id(text) 104 | # text.to_slug.normalize! :transliterations => [:russian, :latin] 105 | # end 106 | # } 107 | end 108 | -------------------------------------------------------------------------------- /lib/friendly_id.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'active_record' 3 | require "friendly_id/base" 4 | require "friendly_id/object_utils" 5 | require "friendly_id/configuration" 6 | require "friendly_id/finder_methods" 7 | 8 | =begin 9 | 10 | ## About FriendlyId 11 | 12 | FriendlyId is an add-on to Ruby's Active Record that allows you to replace ids 13 | in your URLs with strings: 14 | 15 | # without FriendlyId 16 | http://example.com/states/4323454 17 | 18 | # with FriendlyId 19 | http://example.com/states/washington 20 | 21 | It requires few changes to your application code and offers flexibility, 22 | performance and a well-documented codebase. 23 | 24 | ### Core Concepts 25 | 26 | #### Slugs 27 | 28 | The concept of *slugs* is at the heart of FriendlyId. 29 | 30 | A slug is the part of a URL which identifies a page using human-readable 31 | keywords, rather than an opaque identifier such as a numeric id. This can make 32 | your application more friendly both for users and search engines. 33 | 34 | #### Finders: Slugs Act Like Numeric IDs 35 | 36 | To the extent possible, FriendlyId lets you treat text-based identifiers like 37 | normal IDs. This means that you can perform finds with slugs just like you do 38 | with numeric ids: 39 | 40 | Person.find(82542335) 41 | Person.friendly.find("joe") 42 | 43 | =end 44 | module FriendlyId 45 | 46 | autoload :History, "friendly_id/history" 47 | autoload :Slug, "friendly_id/slug" 48 | autoload :SimpleI18n, "friendly_id/simple_i18n" 49 | autoload :Reserved, "friendly_id/reserved" 50 | autoload :Scoped, "friendly_id/scoped" 51 | autoload :Slugged, "friendly_id/slugged" 52 | autoload :Finders, "friendly_id/finders" 53 | autoload :SequentiallySlugged, "friendly_id/sequentially_slugged" 54 | 55 | # FriendlyId takes advantage of `extended` to do basic model setup, primarily 56 | # extending {FriendlyId::Base} to add {FriendlyId::Base#friendly_id 57 | # friendly_id} as a class method. 58 | # 59 | # Previous versions of FriendlyId simply patched ActiveRecord::Base, but this 60 | # version tries to be less invasive. 61 | # 62 | # In addition to adding {FriendlyId::Base#friendly_id friendly_id}, the class 63 | # instance variable +@friendly_id_config+ is added. This variable is an 64 | # instance of an anonymous subclass of {FriendlyId::Configuration}. This 65 | # allows subsequently loaded modules like {FriendlyId::Slugged} and 66 | # {FriendlyId::Scoped} to add functionality to the configuration class only 67 | # for the current class, rather than monkey patching 68 | # {FriendlyId::Configuration} directly. This isolates other models from large 69 | # feature changes an addon to FriendlyId could potentially introduce. 70 | # 71 | # The upshot of this is, you can have two Active Record models that both have 72 | # a @friendly_id_config, but each config object can have different methods 73 | # and behaviors depending on what modules have been loaded, without 74 | # conflicts. Keep this in mind if you're hacking on FriendlyId. 75 | # 76 | # For examples of this, see the source for {Scoped.included}. 77 | def self.extended(model_class) 78 | return if model_class.respond_to? :friendly_id 79 | class << model_class 80 | alias relation_without_friendly_id relation 81 | end 82 | model_class.class_eval do 83 | extend Base 84 | @friendly_id_config = Class.new(Configuration).new(self) 85 | FriendlyId.defaults.call @friendly_id_config 86 | include Model 87 | end 88 | end 89 | 90 | # Allow developers to `include` FriendlyId or `extend` it. 91 | def self.included(model_class) 92 | model_class.extend self 93 | end 94 | 95 | # Set global defaults for all models using FriendlyId. 96 | # 97 | # The default defaults are to use the `:reserved` module and nothing else. 98 | # 99 | # @example 100 | # FriendlyId.defaults do |config| 101 | # config.base :name 102 | # config.use :slugged 103 | # end 104 | def self.defaults(&block) 105 | @defaults = block if block_given? 106 | @defaults ||= ->(config) {config.use :reserved} 107 | end 108 | 109 | # Set the ActiveRecord table name prefix to friendly_id_ 110 | # 111 | # This makes 'slugs' into 'friendly_id_slugs' and also respects any 112 | # 'global' table_name_prefix set on ActiveRecord::Base. 113 | def self.table_name_prefix 114 | "#{ActiveRecord::Base.table_name_prefix}friendly_id_" 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /test/simple_i18n_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | class SimpleI18nTest < TestCaseClass 4 | include FriendlyId::Test 5 | 6 | class Journalist < ActiveRecord::Base 7 | extend FriendlyId 8 | friendly_id :name, :use => :simple_i18n 9 | end 10 | 11 | def setup 12 | I18n.locale = :en 13 | end 14 | 15 | test "friendly_id should return the current locale's slug" do 16 | journalist = Journalist.new(:name => "John Doe") 17 | journalist.slug_es = "juan-fulano" 18 | journalist.valid? 19 | I18n.with_locale(I18n.default_locale) do 20 | assert_equal "john-doe", journalist.friendly_id 21 | end 22 | I18n.with_locale(:es) do 23 | assert_equal "juan-fulano", journalist.friendly_id 24 | end 25 | end 26 | 27 | test "should create record with slug in column for the current locale" do 28 | I18n.with_locale(I18n.default_locale) do 29 | journalist = Journalist.new(:name => "John Doe") 30 | journalist.valid? 31 | assert_equal "john-doe", journalist.slug_en 32 | assert_nil journalist.slug_es 33 | end 34 | I18n.with_locale(:es) do 35 | journalist = Journalist.new(:name => "John Doe") 36 | journalist.valid? 37 | assert_equal "john-doe", journalist.slug_es 38 | assert_nil journalist.slug_en 39 | end 40 | end 41 | 42 | test "to_param should return the numeric id when there's no slug for the current locale" do 43 | transaction do 44 | journalist = Journalist.new(:name => "Juan Fulano") 45 | I18n.with_locale(:es) do 46 | journalist.save! 47 | assert_equal "juan-fulano", journalist.to_param 48 | end 49 | assert_equal journalist.id.to_s, journalist.to_param 50 | end 51 | end 52 | 53 | test "should set friendly id for locale" do 54 | transaction do 55 | journalist = Journalist.create!(:name => "John Smith") 56 | journalist.set_friendly_id("Juan Fulano", :es) 57 | journalist.save! 58 | assert_equal "juan-fulano", journalist.slug_es 59 | I18n.with_locale(:es) do 60 | assert_equal "juan-fulano", journalist.to_param 61 | end 62 | end 63 | end 64 | 65 | test "set friendly_id should fall back default locale when none is given" do 66 | transaction do 67 | journalist = I18n.with_locale(:es) do 68 | Journalist.create!(:name => "Juan Fulano") 69 | end 70 | journalist.set_friendly_id("John Doe") 71 | journalist.save! 72 | assert_equal "john-doe", journalist.slug_en 73 | end 74 | end 75 | 76 | test "should sequence localized slugs" do 77 | transaction do 78 | journalist = Journalist.create!(:name => "John Smith") 79 | I18n.with_locale(:es) do 80 | Journalist.create!(:name => "Juan Fulano") 81 | end 82 | journalist.set_friendly_id("Juan Fulano", :es) 83 | journalist.save! 84 | assert_equal "john-smith", journalist.to_param 85 | I18n.with_locale(:es) do 86 | assert_match(/juan-fulano-.+/, journalist.to_param) 87 | end 88 | end 89 | end 90 | 91 | class RegressionTest < TestCaseClass 92 | include FriendlyId::Test 93 | 94 | test "should not overwrite other locale's slugs on update" do 95 | transaction do 96 | journalist = Journalist.create!(:name => "John Smith") 97 | journalist.set_friendly_id("Juan Fulano", :es) 98 | journalist.save! 99 | assert_equal "john-smith", journalist.to_param 100 | journalist.slug = nil 101 | journalist.update :name => "Johnny Smith" 102 | assert_equal "johnny-smith", journalist.to_param 103 | I18n.with_locale(:es) do 104 | assert_equal "juan-fulano", journalist.to_param 105 | end 106 | end 107 | end 108 | end 109 | 110 | class ConfigurationTest < TestCaseClass 111 | test "should add locale to slug column for a non-default locale" do 112 | I18n.with_locale :es do 113 | assert_equal "slug_es", Journalist.friendly_id_config.slug_column 114 | end 115 | end 116 | 117 | test "should add locale to non-default slug column and non-default locale" do 118 | model_class = Class.new(ActiveRecord::Base) do 119 | self.abstract_class = true 120 | extend FriendlyId 121 | friendly_id :name, :use => :simple_i18n, :slug_column => :foo 122 | end 123 | I18n.with_locale :es do 124 | assert_equal "foo_es", model_class.friendly_id_config.slug_column 125 | end 126 | end 127 | 128 | test "should add locale to slug column for default locale" do 129 | I18n.with_locale(I18n.default_locale) do 130 | assert_equal "slug_en", Journalist.friendly_id_config.slug_column 131 | end 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /lib/friendly_id/history.rb: -------------------------------------------------------------------------------- 1 | module FriendlyId 2 | 3 | =begin 4 | 5 | ## History: Avoiding 404's When Slugs Change 6 | 7 | FriendlyId's {FriendlyId::History History} module adds the ability to store a 8 | log of a model's slugs, so that when its friendly id changes, it's still 9 | possible to perform finds by the old id. 10 | 11 | The primary use case for this is avoiding broken URLs. 12 | 13 | ### Setup 14 | 15 | In order to use this module, you must add a table to your database schema to 16 | store the slug records. FriendlyId provides a generator for this purpose: 17 | 18 | rails generate friendly_id 19 | rake db:migrate 20 | 21 | This will add a table named `friendly_id_slugs`, used by the {FriendlyId::Slug} 22 | model. 23 | 24 | ### Considerations 25 | 26 | Because recording slug history requires creating additional database records, 27 | this module has an impact on the performance of the associated model's `create` 28 | method. 29 | 30 | ### Example 31 | 32 | class Post < ActiveRecord::Base 33 | extend FriendlyId 34 | friendly_id :title, :use => :history 35 | end 36 | 37 | class PostsController < ApplicationController 38 | 39 | before_filter :find_post 40 | 41 | ... 42 | 43 | def find_post 44 | @post = Post.friendly.find params[:id] 45 | 46 | # If an old id or a numeric id was used to find the record, then 47 | # the request slug will not match the current slug, and we should do 48 | # a 301 redirect to the new path 49 | if params[:id] != @post.slug 50 | return redirect_to @post, :status => :moved_permanently 51 | end 52 | end 53 | end 54 | =end 55 | module History 56 | 57 | module Configuration 58 | def dependent_value 59 | dependent.nil? ? :destroy : dependent 60 | end 61 | end 62 | 63 | def self.setup(model_class) 64 | model_class.instance_eval do 65 | friendly_id_config.use :slugged 66 | friendly_id_config.class.send :include, History::Configuration 67 | friendly_id_config.finder_methods = FriendlyId::History::FinderMethods 68 | FriendlyId::Finders.setup(model_class) if friendly_id_config.uses? :finders 69 | end 70 | end 71 | 72 | # Configures the model instance to use the History add-on. 73 | def self.included(model_class) 74 | model_class.class_eval do 75 | has_many :slugs, -> {order(id: :desc)}, { 76 | :as => :sluggable, 77 | :dependent => @friendly_id_config.dependent_value, 78 | :class_name => Slug.to_s 79 | } 80 | 81 | after_save :create_slug 82 | end 83 | end 84 | 85 | module FinderMethods 86 | include ::FriendlyId::FinderMethods 87 | 88 | def exists_by_friendly_id?(id) 89 | super || joins(:slugs).where(slug_history_clause(id)).exists? 90 | end 91 | 92 | private 93 | 94 | def first_by_friendly_id(id) 95 | super || slug_table_record(id) 96 | end 97 | 98 | def slug_table_record(id) 99 | select(quoted_table_name + '.*').joins(:slugs).where(slug_history_clause(id)).order(Slug.arel_table[:id].desc).first 100 | end 101 | 102 | def slug_history_clause(id) 103 | Slug.arel_table[:sluggable_type].eq(base_class.to_s).and(Slug.arel_table[:slug].eq(id)) 104 | end 105 | end 106 | 107 | private 108 | 109 | # If we're updating, don't consider historic slugs for the same record 110 | # to be conflicts. This will allow a record to revert to a previously 111 | # used slug. 112 | def scope_for_slug_generator 113 | relation = super.joins(:slugs) 114 | unless new_record? 115 | relation = relation.merge(Slug.where('sluggable_id <> ?', id)) 116 | end 117 | if friendly_id_config.uses?(:scoped) 118 | relation = relation.where(Slug.arel_table[:scope].eq(serialized_scope)) 119 | end 120 | relation 121 | end 122 | 123 | def create_slug 124 | return unless friendly_id 125 | return if history_is_up_to_date? 126 | # Allow reversion back to a previously used slug 127 | relation = slugs.where(:slug => friendly_id) 128 | if friendly_id_config.uses?(:scoped) 129 | relation = relation.where(:scope => serialized_scope) 130 | end 131 | relation.destroy_all unless relation.empty? 132 | slugs.create! do |record| 133 | record.slug = friendly_id 134 | record.scope = serialized_scope if friendly_id_config.uses?(:scoped) 135 | end 136 | end 137 | 138 | def history_is_up_to_date? 139 | latest_history = slugs.first 140 | check = latest_history.try(:slug) == friendly_id 141 | if friendly_id_config.uses?(:scoped) 142 | check = check && latest_history.scope == serialized_scope 143 | end 144 | check 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /UPGRADING.md: -------------------------------------------------------------------------------- 1 | ## Articles 2 | 3 | * [Migrating an ad-hoc URL slug system to FriendlyId](http://olivierlacan.com/posts/migrating-an-ad-hoc-url-slug-system-to-friendly-id/) 4 | * [Pretty URLs with FriendlyId](http://railscasts.com/episodes/314-pretty-urls-with-friendlyid) 5 | 6 | ## Docs 7 | 8 | The most current docs from the master branch can always be found 9 | [here](http://norman.github.io/friendly_id). 10 | 11 | Docs for older versions are also available: 12 | 13 | * [5.0](http://norman.github.io/friendly_id/5.0/) 14 | * [4.0](http://norman.github.io/friendly_id/4.0/) 15 | * [3.3](http://norman.github.io/friendly_id/3.3/) 16 | * [2.3](http://norman.github.io/friendly_id/2.3/) 17 | 18 | ## What Changed in Version 5.1 19 | 20 | 5.1 is a bugfix release, but bumps the minor version because some applications may be dependent 21 | on the previously buggy behavior. The changes include: 22 | 23 | * Blank strings can no longer be used as slugs. 24 | * When the first slug candidate is rejected because it is reserved, additional candidates will 25 | now be considered before marking the record as invalid. 26 | * The `:finders` module is now compatible with Rails 4.2. 27 | 28 | ## What Changed in Version 5.0 29 | 30 | As of version 5.0, FriendlyId uses [semantic versioning](http://semver.org/). Therefore, as you might 31 | infer from the version number, 5.0 introduces changes incompatible with 4.0. 32 | 33 | The most important changes are: 34 | 35 | * Finders are no longer overridden by default. If you want to do friendly finds, 36 | you must do `Model.friendly.find` rather than `Model.find`. You can however 37 | restore FriendlyId 4-style finders by using the `:finders` addon: 38 | 39 | ```ruby 40 | friendly_id :foo, use: :slugged # you must do MyClass.friendly.find('bar') 41 | # or... 42 | friendly_id :foo, use: [:slugged, :finders] # you can now do MyClass.find('bar') 43 | ``` 44 | * A new "candidates" functionality which makes it easy to set up a list of 45 | alternate slugs that can be used to uniquely distinguish records, rather than 46 | appending a sequence. For example: 47 | 48 | ```ruby 49 | class Restaurant < ActiveRecord::Base 50 | extend FriendlyId 51 | friendly_id :slug_candidates, use: :slugged 52 | 53 | # Try building a slug based on the following fields in 54 | # increasing order of specificity. 55 | def slug_candidates 56 | [ 57 | :name, 58 | [:name, :city], 59 | [:name, :street, :city], 60 | [:name, :street_number, :street, :city] 61 | ] 62 | end 63 | end 64 | ``` 65 | * Now that candidates have been added, FriendlyId no longer uses a numeric 66 | sequence to differentiate conflicting slug, but rather a UUID (e.g. something 67 | like `2bc08962-b3dd-4f29-b2e6-244710c86106`). This makes the 68 | codebase simpler and more reliable when running concurrently, at the expense 69 | of uglier ids being generated when there are conflicts. 70 | * The default sequence separator has been changed from two dashes to one dash. 71 | * Slugs are no longer regenerated when a record is saved. If you want to regenerate 72 | a slug, you must explicitly set the slug column to nil: 73 | 74 | ```ruby 75 | restaurant.friendly_id # joes-diner 76 | restaurant.name = "The Plaza Diner" 77 | restaurant.save! 78 | restaurant.friendly_id # joes-diner 79 | restaurant.slug = nil 80 | restaurant.save! 81 | restaurant.friendly_id # the-plaza-diner 82 | ``` 83 | 84 | You can restore some of the old behavior by overriding the 85 | `should_generate_new_friendly_id?` method. 86 | * The `friendly_id` Rails generator now generates an initializer showing you 87 | how to do some common global configuration. 88 | * The Globalize plugin has moved to a [separate gem](https://github.com/norman/friendly_id-globalize) (currently in alpha). 89 | * The `:reserved` module no longer includes any default reserved words. 90 | Previously it blocked "edit" and "new" everywhere. The default word list has 91 | been moved to `config/initializers/friendly_id.rb` and now includes many more 92 | words. 93 | * The `:history` and `:scoped` addons can now be used together. 94 | * Since it now requires Rails 4, FriendlyId also now requires Ruby 1.9.3 or 95 | higher. 96 | 97 | ## Upgrading from FriendlyId 4.0 98 | 99 | Run `rails generate friendly_id --skip-migration` and edit the initializer 100 | generated in `config/initializers/friendly_id.rb`. This file contains notes 101 | describing how to restore (or not) some of the defaults from FriendlyId 4.0. 102 | 103 | If you want to use the `:history` and `:scoped` addons together, you must add a 104 | `:scope` column to your friendly_id_slugs table and replace the unique index on 105 | `:slug` and `:sluggable_type` with a unique index on those two columns, plus 106 | the new `:scope` column. 107 | 108 | A migration like this should be sufficient: 109 | 110 | ```ruby 111 | add_column :friendly_id_slugs, :scope, :string 112 | remove_index :friendly_id_slugs, [:slug, :sluggable_type] 113 | add_index :friendly_id_slugs, [:slug, :sluggable_type] 114 | add_index :friendly_id_slugs, [:slug, :sluggable_type, :scope], unique: true 115 | ``` 116 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/norman/friendly_id/workflows/CI/badge.svg)](https://github.com/norman/friendly_id/actions) 2 | [![Code Climate](https://codeclimate.com/github/norman/friendly_id.svg)](https://codeclimate.com/github/norman/friendly_id) 3 | [![Inline docs](http://inch-ci.org/github/norman/friendly_id.svg?branch=master)](http://inch-ci.org/github/norman/friendly_id) 4 | 5 | # FriendlyId 6 | 7 | **For the most complete, user-friendly documentation, see the [FriendlyId Guide](http://norman.github.io/friendly_id/file.Guide.html).** 8 | 9 | FriendlyId is the "Swiss Army bulldozer" of slugging and permalink plugins for 10 | Active Record. It lets you create pretty URLs and work with human-friendly 11 | strings as if they were numeric ids. 12 | 13 | With FriendlyId, it's easy to make your application use URLs like: 14 | 15 | http://example.com/states/washington 16 | 17 | instead of: 18 | 19 | http://example.com/states/4323454 20 | 21 | 22 | ## Getting Help 23 | 24 | Ask questions on [Stack Overflow](http://stackoverflow.com/questions/tagged/friendly-id) 25 | using the "friendly-id" tag, and for bugs have a look at [the bug section](https://github.com/norman/friendly_id#bugs) 26 | 27 | ## FriendlyId Features 28 | 29 | FriendlyId offers many advanced features, including: 30 | 31 | * slug history and versioning 32 | * i18n 33 | * scoped slugs 34 | * reserved words 35 | * custom slug generators 36 | 37 | ## Usage 38 | 39 | Add this line to your application's Gemfile: 40 | 41 | ```ruby 42 | gem 'friendly_id', '~> 5.2.4' # Note: You MUST use 5.0.0 or greater for Rails 4.0+ 43 | ``` 44 | 45 | And then execute: 46 | 47 | ```shell 48 | bundle install 49 | ``` 50 | 51 | Add a `slug` column to the desired table (e.g. `Users`) 52 | ```shell 53 | rails g migration AddSlugToUsers slug:uniq 54 | ``` 55 | 56 | Generate the friendly configuration file and a new migration 57 | 58 | ```shell 59 | rails generate friendly_id 60 | ``` 61 | 62 | Note: You can delete the `CreateFriendlyIdSlugs` migration if you won't use the slug history feature. ([Read more](https://norman.github.io/friendly_id/FriendlyId/History.html)) 63 | 64 | Run the migration scripts 65 | 66 | ```shell 67 | rails db:migrate 68 | ``` 69 | 70 | Edit the `app/models/user.rb` file as the following: 71 | 72 | ```ruby 73 | class User < ApplicationRecord 74 | extend FriendlyId 75 | friendly_id :name, use: :slugged 76 | end 77 | ``` 78 | 79 | Edit the `app/controllers/users_controller.rb` file and replace `User.find` by `User.friendly.find` 80 | 81 | ```ruby 82 | class UserController < ApplicationController 83 | def show 84 | @user = User.friendly.find(params[:id]) 85 | end 86 | end 87 | ``` 88 | 89 | Now when you create a new user like the following: 90 | 91 | ```ruby 92 | User.create! name: "Joe Schmoe" 93 | ``` 94 | 95 | You can then access the user show page using the URL http://localhost:3000/users/joe-schmoe. 96 | 97 | 98 | If you're adding FriendlyId to an existing app and need to generate slugs for 99 | existing users, do this from the console, runner, or add a Rake task: 100 | 101 | ```ruby 102 | User.find_each(&:save) 103 | ``` 104 | 105 | ## Bugs 106 | 107 | Please report them on the [Github issue 108 | tracker](http://github.com/norman/friendly_id/issues) for this project. 109 | 110 | If you have a bug to report, please include the following information: 111 | 112 | * **Version information for FriendlyId, Rails and Ruby.** 113 | * Full stack trace and error message (if you have them). 114 | * Any snippets of relevant model, view or controller code that shows how you 115 | are using FriendlyId. 116 | 117 | If you are able to, it helps even more if you can fork FriendlyId on Github, 118 | and add a test that reproduces the error you are experiencing. 119 | 120 | For more inspiration on how to report bugs, please see [this 121 | article](https://www.chiark.greenend.org.uk/~sgtatham/bugs.html). 122 | 123 | ## Thanks and Credits 124 | 125 | FriendlyId was originally created by Norman Clarke and Adrian Mugnolo, with 126 | significant help early in its life by Emilio Tagua. It is now maintained by 127 | Norman Clarke and Philip Arndt. 128 | 129 | We're deeply grateful for the generous contributions over the years from [many 130 | volunteers](https://github.com/norman/friendly_id/contributors). 131 | 132 | ## License 133 | 134 | Copyright (c) 2008-2016 Norman Clarke and contributors, released under the MIT 135 | license. 136 | 137 | Permission is hereby granted, free of charge, to any person obtaining a copy of 138 | this software and associated documentation files (the "Software"), to deal in 139 | the Software without restriction, including without limitation the rights to 140 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 141 | of the Software, and to permit persons to whom the Software is furnished to do 142 | so, subject to the following conditions: 143 | 144 | The above copyright notice and this permission notice shall be included in all 145 | copies or substantial portions of the Software. 146 | 147 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 148 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 149 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 150 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 151 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 152 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 153 | SOFTWARE. 154 | -------------------------------------------------------------------------------- /lib/friendly_id/scoped.rb: -------------------------------------------------------------------------------- 1 | require "friendly_id/slugged" 2 | 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 104 | module Scoped 105 | 106 | # FriendlyId::Config.use will invoke this method when present, to allow 107 | # loading dependent modules prior to overriding them when necessary. 108 | def self.setup(model_class) 109 | model_class.friendly_id_config.use :slugged 110 | end 111 | 112 | # Sets up behavior and configuration options for FriendlyId's scoped slugs 113 | # feature. 114 | def self.included(model_class) 115 | model_class.class_eval do 116 | friendly_id_config.class.send :include, Configuration 117 | end 118 | end 119 | 120 | def serialized_scope 121 | friendly_id_config.scope_columns.sort.map { |column| "#{column}:#{send(column)}" }.join(",") 122 | end 123 | 124 | def scope_for_slug_generator 125 | if friendly_id_config.uses?(:History) 126 | return super 127 | end 128 | relation = self.class.base_class.unscoped.friendly 129 | friendly_id_config.scope_columns.each do |column| 130 | relation = relation.where(column => send(column)) 131 | end 132 | primary_key_name = self.class.primary_key 133 | relation.where(self.class.arel_table[primary_key_name].not_eq(send(primary_key_name))) 134 | end 135 | private :scope_for_slug_generator 136 | 137 | def slug_generator 138 | friendly_id_config.slug_generator_class.new(scope_for_slug_generator, friendly_id_config) 139 | end 140 | private :slug_generator 141 | 142 | def should_generate_new_friendly_id? 143 | (changed & friendly_id_config.scope_columns).any? || super 144 | end 145 | 146 | # This module adds the `:scope` configuration option to 147 | # {FriendlyId::Configuration FriendlyId::Configuration}. 148 | module Configuration 149 | 150 | # Gets the scope value. 151 | # 152 | # When setting this value, the argument should be a symbol referencing a 153 | # `belongs_to` relation, or a column. 154 | # 155 | # @return Symbol The scope value 156 | attr_accessor :scope 157 | 158 | # Gets the scope columns. 159 | # 160 | # Checks to see if the `:scope` option passed to 161 | # {FriendlyId::Base#friendly_id} refers to a relation, and if so, returns 162 | # the realtion's foreign key. Otherwise it assumes the option value was 163 | # the name of column and returns it cast to a String. 164 | # 165 | # @return String The scope column 166 | def scope_columns 167 | [@scope].flatten.map { |s| (reflection_foreign_key(s) or s).to_s } 168 | end 169 | 170 | private 171 | 172 | def reflection_foreign_key(scope) 173 | reflection = model_class.reflections[scope] || model_class.reflections[scope.to_s] 174 | reflection.try(:foreign_key) 175 | end 176 | end 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /test/sequentially_slugged_test.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | class Article < ActiveRecord::Base 4 | extend FriendlyId 5 | friendly_id :name, :use => :sequentially_slugged 6 | end 7 | 8 | class SequentiallySluggedTest < TestCaseClass 9 | include FriendlyId::Test 10 | include FriendlyId::Test::Shared::Core 11 | 12 | def model_class 13 | Article 14 | end 15 | 16 | test "should generate numerically sequential slugs" do 17 | transaction do 18 | records = 12.times.map { model_class.create! :name => "Some news" } 19 | assert_equal "some-news", records[0].slug 20 | (1...12).each {|i| assert_equal "some-news-#{i + 1}", records[i].slug} 21 | end 22 | end 23 | 24 | test "should cope when slugs are missing from the sequence" do 25 | transaction do 26 | record_1 = model_class.create!(:name => 'A thing') 27 | record_2 = model_class.create!(:name => 'A thing') 28 | record_3 = model_class.create!(:name => 'A thing') 29 | 30 | assert_equal 'a-thing', record_1.slug 31 | assert_equal 'a-thing-2', record_2.slug 32 | assert_equal 'a-thing-3', record_3.slug 33 | 34 | record_2.destroy 35 | 36 | record_4 = model_class.create!(:name => 'A thing') 37 | 38 | assert_equal 'a-thing-4', record_4.slug 39 | end 40 | end 41 | 42 | test "should cope with strange column names" do 43 | model_class = Class.new(ActiveRecord::Base) do 44 | self.table_name = "journalists" 45 | extend FriendlyId 46 | friendly_id :name, :use => :sequentially_slugged, :slug_column => "strange name" 47 | end 48 | 49 | transaction do 50 | record_1 = model_class.create! name: "Julian Assange" 51 | record_2 = model_class.create! name: "Julian Assange" 52 | 53 | assert_equal 'julian-assange', record_1.attributes["strange name"] 54 | assert_equal 'julian-assange-2', record_2.attributes["strange name"] 55 | end 56 | end 57 | 58 | test "should correctly sequence slugs that end in a number" do 59 | transaction do 60 | record1 = model_class.create! :name => "Peugeuot 206" 61 | assert_equal "peugeuot-206", record1.slug 62 | record2 = model_class.create! :name => "Peugeuot 206" 63 | assert_equal "peugeuot-206-2", record2.slug 64 | end 65 | end 66 | 67 | test "should correctly sequence slugs that begin with a number" do 68 | transaction do 69 | record1 = model_class.create! :name => "2010 to 2015 Records" 70 | assert_equal "2010-to-2015-records", record1.slug 71 | record2 = model_class.create! :name => "2010 to 2015 Records" 72 | assert_equal "2010-to-2015-records-2", record2.slug 73 | end 74 | end 75 | 76 | test "should sequence with a custom sequence separator" do 77 | model_class = Class.new(ActiveRecord::Base) do 78 | self.table_name = "novelists" 79 | extend FriendlyId 80 | friendly_id :name, :use => :sequentially_slugged, :sequence_separator => ':' 81 | end 82 | 83 | transaction do 84 | record_1 = model_class.create! name: "Julian Barnes" 85 | record_2 = model_class.create! name: "Julian Barnes" 86 | 87 | assert_equal 'julian-barnes', record_1.slug 88 | assert_equal 'julian-barnes:2', record_2.slug 89 | end 90 | end 91 | 92 | test "should not generate a slug when canidates set is empty" do 93 | model_class = Class.new(ActiveRecord::Base) do 94 | self.table_name = "cities" 95 | extend FriendlyId 96 | friendly_id :slug_candidates, :use => [ :sequentially_slugged ] 97 | 98 | def slug_candidates 99 | [name, [name, code]] 100 | end 101 | end 102 | transaction do 103 | record = model_class.create!(:name => nil, :code => nil) 104 | assert_nil record.slug 105 | end 106 | end 107 | 108 | test "should not generate a slug when the sluggable attribute is blank" do 109 | record = model_class.create!(:name => '') 110 | assert_nil record.slug 111 | end 112 | end 113 | 114 | class SequentiallySluggedTestWithHistory < TestCaseClass 115 | include FriendlyId::Test 116 | include FriendlyId::Test::Shared::Core 117 | 118 | class Article < ActiveRecord::Base 119 | extend FriendlyId 120 | friendly_id :name, :use => [:sequentially_slugged, :history] 121 | end 122 | 123 | def model_class 124 | Article 125 | end 126 | 127 | test "should work with regeneration with history when slug already exists" do 128 | transaction do 129 | record1 = model_class.create! :name => "Test name" 130 | record2 = model_class.create! :name => "Another test name" 131 | assert_equal 'test-name', record1.slug 132 | assert_equal 'another-test-name', record2.slug 133 | 134 | record2.name = "Test name" 135 | record2.slug = nil 136 | record2.save! 137 | assert_equal 'test-name-2', record2.slug 138 | end 139 | end 140 | 141 | test "should work with regeneration with history when 2 slugs already exists and the second is changed" do 142 | transaction do 143 | record1 = model_class.create! :name => "Test name" 144 | record2 = model_class.create! :name => "Test name" 145 | record3 = model_class.create! :name => "Another test name" 146 | assert_equal 'test-name', record1.slug 147 | assert_equal 'test-name-2', record2.slug 148 | assert_equal 'another-test-name', record3.slug 149 | 150 | record2.name = "One more test name" 151 | record2.slug = nil 152 | record2.save! 153 | assert_equal 'one-more-test-name', record2.slug 154 | 155 | record3.name = "Test name" 156 | record3.slug = nil 157 | record3.save! 158 | assert_equal 'test-name-3', record3.slug 159 | end 160 | end 161 | end 162 | 163 | class City < ActiveRecord::Base 164 | has_many :restaurants 165 | end 166 | 167 | class Restaurant < ActiveRecord::Base 168 | extend FriendlyId 169 | belongs_to :city 170 | friendly_id :name, :use => [:sequentially_slugged, :scoped, :history], :scope => :city 171 | end 172 | 173 | class SequentiallySluggedTestWithScopedHistory < TestCaseClass 174 | include FriendlyId::Test 175 | include FriendlyId::Test::Shared::Core 176 | 177 | def model_class 178 | Restaurant 179 | end 180 | 181 | test "should work with regeneration with scoped history" do 182 | transaction do 183 | city1 = City.create! 184 | city2 = City.create! 185 | record1 = model_class.create! :name => "Test name", :city => city1 186 | record2 = model_class.create! :name => "Test name", :city => city1 187 | 188 | assert_equal 'test-name', record1.slug 189 | assert_equal 'test-name-2', record2.slug 190 | 191 | record2.name = 'Another test name' 192 | record2.slug = nil 193 | record2.save! 194 | 195 | record3 = model_class.create! :name => "Test name", :city => city1 196 | assert_equal 'test-name-3', record3.slug 197 | end 198 | end 199 | end 200 | -------------------------------------------------------------------------------- /test/shared.rb: -------------------------------------------------------------------------------- 1 | module FriendlyId 2 | module Test 3 | module Shared 4 | 5 | module Slugged 6 | test "configuration should have a sequence_separator" do 7 | assert !model_class.friendly_id_config.sequence_separator.empty? 8 | end 9 | 10 | test "should make a new slug if the slug has been set to nil changed" do 11 | with_instance_of model_class do |record| 12 | record.name = "Changed Value" 13 | record.slug = nil 14 | record.save! 15 | assert_equal "changed-value", record.slug 16 | end 17 | end 18 | 19 | test "should add a UUID for duplicate friendly ids" do 20 | with_instance_of model_class do |record| 21 | record2 = model_class.create! :name => record.name 22 | assert record2.friendly_id.match(/([0-9a-z]+\-){4}[0-9a-z]+\z/) 23 | end 24 | end 25 | 26 | test "should not add slug sequence on update after other conflicting slugs were added" do 27 | with_instance_of model_class do |record| 28 | old = record.friendly_id 29 | model_class.create! :name => record.name 30 | record.save! 31 | record.reload 32 | assert_equal old, record.to_param 33 | end 34 | end 35 | 36 | test "should not change the sequence on save" do 37 | with_instance_of model_class do |record| 38 | record2 = model_class.create! :name => record.name 39 | friendly_id = record2.friendly_id 40 | record2.active = !record2.active 41 | record2.save! 42 | assert_equal friendly_id, record2.reload.friendly_id 43 | end 44 | end 45 | 46 | test "should create slug on save if the slug is nil" do 47 | with_instance_of model_class do |record| 48 | record.slug = nil 49 | record.save! 50 | refute_nil record.slug 51 | end 52 | end 53 | 54 | test "should set the slug to nil on dup" do 55 | with_instance_of model_class do |record| 56 | record2 = record.dup 57 | assert_nil record2.slug 58 | end 59 | end 60 | 61 | test "when validations block save, to_param should return friendly_id rather than nil" do 62 | my_model_class = Class.new(model_class) 63 | self.class.const_set("Foo", my_model_class) 64 | with_instance_of my_model_class do |record| 65 | record.update my_model_class.friendly_id_config.slug_column => nil 66 | record = my_model_class.friendly.find(record.id) 67 | record.class.validate Proc.new {errors.add(:name, "FAIL")} 68 | record.save 69 | assert_equal record.to_param, record.friendly_id 70 | end 71 | end 72 | end 73 | 74 | module Core 75 | test "finds should respect conditions" do 76 | with_instance_of(model_class) do |record| 77 | assert_raises(ActiveRecord::RecordNotFound) do 78 | model_class.where("1 = 2").friendly.find record.friendly_id 79 | end 80 | assert_raises(ActiveRecord::RecordNotFound) do 81 | model_class.where("1 = 2").friendly.find record.id 82 | end 83 | end 84 | end 85 | 86 | test "should be findable by friendly id" do 87 | with_instance_of(model_class) {|record| assert model_class.friendly.find record.friendly_id} 88 | end 89 | 90 | test "should exist? by friendly id" do 91 | with_instance_of(model_class) do |record| 92 | assert model_class.friendly.exists? record.id 93 | assert model_class.friendly.exists? record.id.to_s 94 | assert model_class.friendly.exists? record.friendly_id 95 | assert model_class.friendly.exists?({:id => record.id}) 96 | assert model_class.friendly.exists?(['id = ?', record.id]) 97 | assert !model_class.friendly.exists?(record.friendly_id + "-hello") 98 | assert !model_class.friendly.exists?(0) 99 | end 100 | end 101 | 102 | test "should be findable by id as integer" do 103 | with_instance_of(model_class) {|record| assert model_class.friendly.find record.id.to_i} 104 | end 105 | 106 | test "should be findable by id as string" do 107 | with_instance_of(model_class) {|record| assert model_class.friendly.find record.id.to_s} 108 | end 109 | 110 | test "should treat numeric part of string as an integer id" do 111 | with_instance_of(model_class) do |record| 112 | assert_raises(ActiveRecord::RecordNotFound) do 113 | model_class.friendly.find "#{record.id}-foo" 114 | end 115 | end 116 | end 117 | 118 | test "should be findable by numeric friendly_id" do 119 | with_instance_of(model_class, :name => "206") {|record| assert model_class.friendly.find record.friendly_id} 120 | end 121 | 122 | test "to_param should return the friendly_id" do 123 | with_instance_of(model_class) {|record| assert_equal record.friendly_id, record.to_param} 124 | end 125 | 126 | if ActiveRecord::VERSION::MAJOR == 4 && ActiveRecord::VERSION::MINOR < 2 127 | test "should be findable by themselves" do 128 | with_instance_of(model_class) {|record| assert_equal record, model_class.friendly.find(record)} 129 | end 130 | end 131 | 132 | test "updating record's other values should not change the friendly_id" do 133 | with_instance_of model_class do |record| 134 | old = record.friendly_id 135 | record.update! active: false 136 | assert model_class.friendly.find old 137 | end 138 | end 139 | 140 | test "instances found by a single id should not be read-only" do 141 | with_instance_of(model_class) {|record| assert !model_class.friendly.find(record.friendly_id).readonly?} 142 | end 143 | 144 | test "failing finds with unfriendly_id should raise errors normally" do 145 | assert_raises(ActiveRecord::RecordNotFound) {model_class.friendly.find 0} 146 | end 147 | 148 | test "should return numeric id if the friendly_id is nil" do 149 | with_instance_of(model_class) do |record| 150 | record.expects(:friendly_id).returns(nil) 151 | assert_equal record.id.to_s, record.to_param 152 | end 153 | end 154 | 155 | test "should return numeric id if the friendly_id is an empty string" do 156 | with_instance_of(model_class) do |record| 157 | record.expects(:friendly_id).returns("") 158 | assert_equal record.id.to_s, record.to_param 159 | end 160 | end 161 | 162 | test "should return the friendly_id as a string" do 163 | with_instance_of(model_class) do |record| 164 | record.expects(:friendly_id).returns(5) 165 | assert_equal "5", record.to_param 166 | end 167 | end 168 | 169 | test "should return numeric id if the friendly_id is blank" do 170 | with_instance_of(model_class) do |record| 171 | record.expects(:friendly_id).returns(" ") 172 | assert_equal record.id.to_s, record.to_param 173 | end 174 | end 175 | 176 | test "should return nil for to_param with a new record" do 177 | assert_nil model_class.new.to_param 178 | end 179 | end 180 | end 181 | end 182 | end 183 | -------------------------------------------------------------------------------- /lib/friendly_id/base.rb: -------------------------------------------------------------------------------- 1 | module FriendlyId 2 | =begin 3 | 4 | ## Setting Up FriendlyId in Your Model 5 | 6 | To use FriendlyId in your ActiveRecord models, you must first either extend or 7 | include the FriendlyId module (it makes no difference), then invoke the 8 | {FriendlyId::Base#friendly_id friendly_id} method to configure your desired 9 | options: 10 | 11 | class Foo < ActiveRecord::Base 12 | include FriendlyId 13 | friendly_id :bar, :use => [:slugged, :simple_i18n] 14 | end 15 | 16 | The most important option is `:use`, which you use to tell FriendlyId which 17 | addons it should use. See the documentation for {FriendlyId::Base#friendly_id} for a list of all 18 | available addons, or skim through the rest of the docs to get a high-level 19 | overview. 20 | 21 | *A note about single table inheritance (STI): you must extend FriendlyId in 22 | all classes that participate in STI, both your parent classes and their 23 | children.* 24 | 25 | ### The Default Setup: Simple Models 26 | 27 | The simplest way to use FriendlyId is with a model that has a uniquely indexed 28 | column with no spaces or special characters, and that is seldom or never 29 | updated. The most common example of this is a user name: 30 | 31 | class User < ActiveRecord::Base 32 | extend FriendlyId 33 | friendly_id :login 34 | validates_format_of :login, :with => /\A[a-z0-9]+\z/i 35 | end 36 | 37 | @user = User.friendly.find "joe" # the old User.find(1) still works, too 38 | @user.to_param # returns "joe" 39 | redirect_to @user # the URL will be /users/joe 40 | 41 | In this case, FriendlyId assumes you want to use the column as-is; it will never 42 | modify the value of the column, and your application should ensure that the 43 | value is unique and admissible in a URL: 44 | 45 | class City < ActiveRecord::Base 46 | extend FriendlyId 47 | friendly_id :name 48 | end 49 | 50 | @city.friendly.find "Viña del Mar" 51 | redirect_to @city # the URL will be /cities/Viña%20del%20Mar 52 | 53 | Writing the code to process an arbitrary string into a good identifier for use 54 | in a URL can be repetitive and surprisingly tricky, so for this reason it's 55 | often better and easier to use {FriendlyId::Slugged slugs}. 56 | 57 | =end 58 | module Base 59 | 60 | # Configure FriendlyId's behavior in a model. 61 | # 62 | # class Post < ActiveRecord::Base 63 | # extend FriendlyId 64 | # friendly_id :title, :use => :slugged 65 | # end 66 | # 67 | # When given the optional block, this method will yield the class's instance 68 | # of {FriendlyId::Configuration} to the block before evaluating other 69 | # arguments, so configuration values set in the block may be overwritten by 70 | # the arguments. This order was chosen to allow passing the same proc to 71 | # multiple models, while being able to override the values it sets. Here is 72 | # a contrived example: 73 | # 74 | # $friendly_id_config_proc = Proc.new do |config| 75 | # config.base = :name 76 | # config.use :slugged 77 | # end 78 | # 79 | # class Foo < ActiveRecord::Base 80 | # extend FriendlyId 81 | # friendly_id &$friendly_id_config_proc 82 | # end 83 | # 84 | # class Bar < ActiveRecord::Base 85 | # extend FriendlyId 86 | # friendly_id :title, &$friendly_id_config_proc 87 | # end 88 | # 89 | # However, it's usually better to use {FriendlyId.defaults} for this: 90 | # 91 | # FriendlyId.defaults do |config| 92 | # config.base = :name 93 | # config.use :slugged 94 | # end 95 | # 96 | # class Foo < ActiveRecord::Base 97 | # extend FriendlyId 98 | # end 99 | # 100 | # class Bar < ActiveRecord::Base 101 | # extend FriendlyId 102 | # friendly_id :title 103 | # end 104 | # 105 | # In general you should use the block syntax either because of your personal 106 | # aesthetic preference, or because you need to share some functionality 107 | # between multiple models that can't be well encapsulated by 108 | # {FriendlyId.defaults}. 109 | # 110 | # ### Order Method Calls in a Block vs Ordering Options 111 | # 112 | # When calling this method without a block, you may set the hash options in 113 | # any order. 114 | # 115 | # However, when using block-style invocation, be sure to call 116 | # FriendlyId::Configuration's {FriendlyId::Configuration#use use} method 117 | # *prior* to the associated configuration options, because it will include 118 | # modules into your class, and these modules in turn may add required 119 | # configuration options to the `@friendly_id_configuraton`'s class: 120 | # 121 | # class Person < ActiveRecord::Base 122 | # friendly_id do |config| 123 | # # This will work 124 | # config.use :slugged 125 | # config.sequence_separator = ":" 126 | # end 127 | # end 128 | # 129 | # class Person < ActiveRecord::Base 130 | # friendly_id do |config| 131 | # # This will fail 132 | # config.sequence_separator = ":" 133 | # config.use :slugged 134 | # end 135 | # end 136 | # 137 | # ### Including Your Own Modules 138 | # 139 | # Because :use can accept a name or a Module, {FriendlyId.defaults defaults} 140 | # can be a convenient place to set up behavior common to all classes using 141 | # FriendlyId. You can include any module, or more conveniently, define one 142 | # on-the-fly. For example, let's say you want to make 143 | # [Babosa](http://github.com/norman/babosa) the default slugging library in 144 | # place of Active Support, and transliterate all slugs from Russian Cyrillic 145 | # to ASCII: 146 | # 147 | # require "babosa" 148 | # 149 | # FriendlyId.defaults do |config| 150 | # config.base = :name 151 | # config.use :slugged 152 | # config.use Module.new { 153 | # def normalize_friendly_id(text) 154 | # text.to_slug.normalize! :transliterations => [:russian, :latin] 155 | # end 156 | # } 157 | # end 158 | # 159 | # 160 | # @option options [Symbol,Module] :use The addon or name of an addon to use. 161 | # By default, FriendlyId provides {FriendlyId::Slugged :slugged}, 162 | # {FriendlyId::Reserved :finders}, {FriendlyId::History :history}, 163 | # {FriendlyId::Reserved :reserved}, {FriendlyId::Scoped :scoped}, and 164 | # {FriendlyId::SimpleI18n :simple_i18n}. 165 | # 166 | # @option options [Array] :reserved_words Available when using `:reserved`, 167 | # which is loaded by default. Sets an array of words banned for use as 168 | # the basis of a friendly_id. By default this includes "edit" and "new". 169 | # 170 | # @option options [Symbol] :scope Available when using `:scoped`. 171 | # Sets the relation or column used to scope generated friendly ids. This 172 | # option has no default value. 173 | # 174 | # @option options [Symbol] :sequence_separator Available when using `:slugged`. 175 | # Configures the sequence of characters used to separate a slug from a 176 | # sequence. Defaults to `-`. 177 | # 178 | # @option options [Symbol] :slug_column Available when using `:slugged`. 179 | # Configures the name of the column where FriendlyId will store the slug. 180 | # Defaults to `:slug`. 181 | # 182 | # @option options [Integer] :slug_limit Available when using `:slugged`. 183 | # Configures the limit of the slug. This option has no default value. 184 | # 185 | # @option options [Symbol] :slug_generator_class Available when using `:slugged`. 186 | # Sets the class used to generate unique slugs. You should not specify this 187 | # unless you're doing some extensive hacking on FriendlyId. Defaults to 188 | # {FriendlyId::SlugGenerator}. 189 | # 190 | # @yield Provides access to the model class's friendly_id_config, which 191 | # allows an alternate configuration syntax, and conditional configuration 192 | # logic. 193 | # 194 | # @option options [Symbol,Boolean] :dependent Available when using `:history`. 195 | # Sets the value used for the slugged association's dependent option. Use 196 | # `false` if you do not want to dependently destroy the associated slugged 197 | # record. Defaults to `:destroy`. 198 | # 199 | # @option options [Symbol] :routes When set to anything other than :friendly, 200 | # ensures that all routes generated by default do *not* use the slug. This 201 | # allows `form_for` and `polymorphic_path` to continue to generate paths like 202 | # `/team/1` instead of `/team/number-one`. You can still generate paths 203 | # like the latter using: team_path(team.slug). When set to :friendly, or 204 | # omitted, the default friendly_id behavior is maintained. 205 | # 206 | # @yieldparam config The model class's {FriendlyId::Configuration friendly_id_config}. 207 | def friendly_id(base = nil, options = {}, &block) 208 | yield friendly_id_config if block_given? 209 | friendly_id_config.dependent = options.delete :dependent 210 | friendly_id_config.use options.delete :use 211 | friendly_id_config.send :set, base ? options.merge(:base => base) : options 212 | include Model 213 | end 214 | 215 | # Returns a scope that includes the friendly finders. 216 | # @see FriendlyId::FinderMethods 217 | def friendly 218 | # Guess what? This causes Rails to invoke `extend` on the scope, which has 219 | # the well-known effect of blowing away Ruby's method cache. It would be 220 | # possible to make this more performant by subclassing the model's 221 | # relation class, extending that, and returning an instance of it in this 222 | # method. FriendlyId 4.0 did something similar. However in 5.0 I've 223 | # decided to only use Rails's public API in order to improve compatibility 224 | # and maintainability. If you'd like to improve the performance, your 225 | # efforts would be best directed at improving it at the root cause 226 | # of the problem - in Rails - because it would benefit more people. 227 | all.extending(friendly_id_config.finder_methods) 228 | end 229 | 230 | # Returns the model class's {FriendlyId::Configuration friendly_id_config}. 231 | # @note In the case of Single Table Inheritance (STI), this method will 232 | # duplicate the parent class's FriendlyId::Configuration and relation class 233 | # on first access. If you're concerned about thread safety, then be sure 234 | # to invoke {#friendly_id} in your class for each model. 235 | def friendly_id_config 236 | @friendly_id_config ||= base_class.friendly_id_config.dup.tap do |config| 237 | config.model_class = self 238 | end 239 | end 240 | 241 | def primary_key_type 242 | @primary_key_type ||= columns_hash[primary_key].type 243 | end 244 | end 245 | 246 | # Instance methods that will be added to all classes using FriendlyId. 247 | module Model 248 | def self.included(model_class) 249 | return if model_class.respond_to?(:friendly) 250 | end 251 | 252 | # Convenience method for accessing the class method of the same name. 253 | def friendly_id_config 254 | self.class.friendly_id_config 255 | end 256 | 257 | # Get the instance's friendly_id. 258 | def friendly_id 259 | send friendly_id_config.query_field 260 | end 261 | 262 | # Either the friendly_id, or the numeric id cast to a string. 263 | def to_param 264 | if friendly_id_config.routes == :friendly 265 | friendly_id.presence.to_param || super 266 | else 267 | super 268 | end 269 | end 270 | 271 | # Clears slug on duplicate records when calling `dup`. 272 | def dup 273 | super.tap { |duplicate| duplicate.slug = nil if duplicate.respond_to?('slug=') } 274 | end 275 | end 276 | end 277 | -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | # FriendlyId Changelog 2 | 3 | We would like to think our many [contributors](https://github.com/norman/friendly_id/graphs/contributors) for 4 | suggestions, ideas and improvements to FriendlyId. 5 | 6 | ## Unreleased 7 | 8 | * Make `first_by_friendly_id` case insensitive using `downcase`. ([#787](https://github.com/norman/friendly_id/pull/787)) 9 | * Use `destroy_all` rather than `delete_all` when creating historical slugs ([#924](https://github.com/norman/friendly_id/pull/924)) 10 | * Avoid using deprecated `update_attributes`. ([#922](https://github.com/norman/friendly_id/pull/922)) 11 | 12 | ## 5.3.0 (2019-09-25) 13 | 14 | * Record history when scope changes but slug does not ([#916](https://github.com/norman/friendly_id/pull/916)) 15 | * Add support for Rails 6 ([#897](https://github.com/norman/friendly_id/pull/897)) 16 | 17 | ## 5.2.5 (2018-12-30) 18 | 19 | * Pass all possible parameters to ActiveRecord::RecordNotFound.new when raising the exception ([#890](https://github.com/norman/friendly_id/pull/890)) 20 | * Use composite index for queries by sluggable ([#882](https://github.com/norman/friendly_id/pull/882)) 21 | * Scoped: generate new slug if scope changed ([#878](https://github.com/norman/friendly_id/pull/878)) 22 | * Fix History + SequentiallySlugged issues ([#877](https://github.com/norman/friendly_id/pull/877)) 23 | * Support scoped with STI ([#745](https://github.com/norman/friendly_id/pull/745)) 24 | * Fix exists? to behave the same as find for numeric slugs ([#875](https://github.com/norman/friendly_id/pull/875)) 25 | * Remove dirty tracking code from to_param ([#867](https://github.com/norman/friendly_id/pull/867)) 26 | 27 | ## 5.2.4 (2018-04-24) 28 | 29 | * Fix compatibility with Rails versions 4.0 -> 5.2. ([#863](https://github.com/norman/friendly_id/pull/863)). 30 | * Refactor `History::FinderMethods` to use base implementation. ([#853](https://github.com/norman/friendly_id/pull/853)). 31 | * Defer loading of ActiveRecord to avoid config issues. ([#852](https://github.com/norman/friendly_id/pull/852)). 32 | * Ensure compatibility with paranoid deletion libraries. ([#838](https://github.com/norman/friendly_id/pull/838)). 33 | * Add treat_reserved_as_conflict option to initializer ([#847](https://github.com/norman/friendly_id/pull/847)). 34 | 35 | ## 5.2.3 (2017-09-22) 36 | 37 | * Added option to treat reserved words as conflicts ([#831](https://github.com/norman/friendly_id/pull/831)). 38 | 39 | ## 5.2.2 (2017-09-13) 40 | 41 | * Prevent warning on db:migrate in Rails 5.1 ([#826](https://github.com/norman/friendly_id/pull/826)). 42 | * Allow to set size limit for slug ([#809](https://github.com/norman/friendly_id/pull/809)). 43 | * Update specs and drop support for ruby 2.0.0 ([#824](https://github.com/norman/friendly_id/pull/824)). 44 | 45 | ## 5.2.1 (2017-04-09) 46 | 47 | * Change ActiveRecord::Base to ApplicationRecord ([#782](https://github.com/norman/friendly_id/pull/782)). 48 | * Refactor `Candidates#each` method. ([#773](https://github.com/norman/friendly_id/pull/773)). 49 | * Assign to configured slug column, not 'slug' when validation fails. ([#779](https://github.com/norman/friendly_id/pull/779)). 50 | * Fix sequential slugs when using History. ([#774](https://github.com/norman/friendly_id/pull/774)). 51 | 52 | ## 5.2.0 (2016-12-01) 53 | 54 | * Add sequential slug module for FriendlyId 4.x-style sequential slugs. ([#644](https://github.com/norman/friendly_id/pull/644)). 55 | * Make Candidates#each iterable without block ([#651](https://github.com/norman/friendly_id/pull/651)). 56 | * Ensure slug history prefers the record that most recently used the slug ([#663](https://github.com/norman/friendly_id/pull/663)). 57 | * Don't calculate all changes just to check if the param field has changed ([#667](https://github.com/norman/friendly_id/pull/667)). 58 | * Don't set or change slug when unrelated validation failures block the record from being saved ([#642](https://github.com/norman/friendly_id/issues/642)). 59 | * Fix order dependence bug between history and finders modules ([#718](https://github.com/norman/friendly_id/pull/718)) 60 | * Added ability to conditionally turn off `:dependent => :destroy` on FriendlyId::Slugs([#724](https://github.com/norman/friendly_id/pull/724)) 61 | * Add support for Rails 5. ([#728](https://github.com/norman/friendly_id/pull/728)) 62 | * Allow per-model conditional disabling of friendly path generation using a :routes option to friendly_id ([#735](https://github.com/norman/friendly_id/pull/735)) 63 | 64 | ## 5.1.0 (2015-01-15) 65 | 66 | * FriendlyId will no longer allow blank strings as slugs ([#571](https://github.com/norman/friendly_id/pull/571)). 67 | * FriendlyId will now try to use the first non-reserved candidate as its 68 | slug and will only mark the record invalid if all candidates ([#536](https://github.com/norman/friendly_id/issues/536)). 69 | * Fix order dependence bug between history and scoped modules ([#588](https://github.com/norman/friendly_id/pull/588)). 70 | * Fix "friendly" finds on Rails 4.2 ([#607](https://github.com/norman/friendly_id/issues/607)). 71 | 72 | ## 5.0.4 (2014-05-29) 73 | 74 | * Bug fix for call to removed `primary` method on Edge Rails. ([#557](https://github.com/norman/friendly_id/pull/557)). 75 | * Bug fix for unwanted slug regeneration when the slug source was changed, but not the actual generated slug ([#563](https://github.com/norman/friendly_id/pull/562)). 76 | * Big fix to look for UUIDs only at the end of slugs ([#548](https://github.com/norman/friendly_id/pull/548)). 77 | * Various documentation and test setup improvements. 78 | 79 | ## 5.0.3 (2013-02-14) 80 | 81 | * Bug fix for calls to #dup with unslugged models ([#518](https://github.com/norman/friendly_id/pull/518)). 82 | * Bug fixes for STI ([#516](https://github.com/norman/friendly_id/pull/516)). 83 | * Bug fix for slug regeneration (both scoped and unscoped) ([#513](https://github.com/norman/friendly_id/pull/513)). 84 | * Bug fix for finds with models that use the :history module ([#509](https://github.com/norman/friendly_id/pull/509)). 85 | 86 | ## 5.0.2 (2013-12-10) 87 | 88 | * Query performance improvements ([#497](https://github.com/norman/friendly_id/pull/497)). 89 | * Documentation improvements (thanks [John Bachir](https://github.com/jjb)). 90 | * Minor refactoring of internals (thanks [Gagan Ahwad](https://github.com/gaganawhad)). 91 | * Set slug to `nil` on call to `dup` to ensure slug is generated ([#483](https://github.com/norman/friendly_id/pull/483)). 92 | 93 | ## 5.0.1 (2013-10-27) 94 | 95 | * Fix compatibility with Rails 4.0.1.rc3 (thanks [Herman verschooten](https://github.com/Hermanverschooten)). 96 | 97 | ## 5.0.0 (2013-10-16) 98 | 99 | * Fix to let scoped records reuse their slugs (thanks [Donny 100 | Kurnia](https://github.com/donnykurnia)). 101 | 102 | ## 5.0.0.rc.3 (2013-10-04) 103 | 104 | * Support friendly finds on associations in Rails 4.0.1 and up. They will 105 | currently work on Rails 4.0 associations only if `:inverse_of` is not used. 106 | In Rails 4-0-stable, associations have been modified to use a special 107 | relation class, giving FriendlyId a consistent extension point. Since the 108 | behavior in 4.0.0 is considered defective and fixed in 4-0-stable, FriendlyId 109 | 5.0 will not support friendly finds on inverse relelations in 4.0.0. For a 110 | reliable workaround, use the `friendly` scope for friendly finds on 111 | associations; this works on all Rails 4.0.x versions and will continue to be 112 | supported. 113 | * Documentation fixes. 114 | 115 | ## 5.0.0.rc2 (2013-09-29) 116 | 117 | * When the :finders addon has been included, use it in FriendlyId's internal 118 | finds to boost performance. 119 | * Use instance methods rather than class methods in migrations. 120 | * On find, fall back to super when the primary key is a character type. Thanks 121 | to [Jamie Davidson](https://github.com/jhdavids8). 122 | * Fix reversion to previously used slug from history table when 123 | `should_generate_new_friendly_id?` is overridden. 124 | * Fix sequencing of numeric slugs 125 | 126 | ## 5.0.0.rc1 (2013-08-28) 127 | 128 | * Removed some outdated tests. 129 | * Improved documentation. 130 | * Removed Guide from repository and added tasks to maintain docs up to date 131 | on Github pages at http://norman.github.io/friendly_id. 132 | 133 | ## 5.0.0.beta4 (2013-08-21) 134 | 135 | * Add an initializer to the generator; move the default reserved words there. 136 | * Allow assignment from {FriendlyId::Configuration#base}. 137 | * Fix bug whereby records could not reuse their own slugs. 138 | 139 | ## 5.0.0.beta3 (2013-08-20) 140 | 141 | * Update gemspec to ensure FriendlyId 5.0 is only used with AR 4.0.x. 142 | 143 | ## 5.0.0.beta2 (2013-08-16) 144 | 145 | * Add "finders" module to easily restore FriendlyId 4.0 finder behavior. 146 | 147 | ## 5.0.0.beta1 (2013-08-10) 148 | 149 | * Support for Rails 4. 150 | * Made the :scoped and :history modules compatible with each other (Andre Duffeck). 151 | * Removed class-level finders in favor of `friendly` scope (Norman Clarke). 152 | * Implemented "candidates" support (Norman Clarke). 153 | * Slug "sequences" are now GUIDs rather than numbers (Norman Clarke). 154 | * `find` no longer falls back to super unless id is fully numeric string (Norman Clarke). 155 | * Default sequence separator is now '-' rather than '--'. 156 | * Support for Globalize has been removed until Globalize supports Rails 4. 157 | * Removed support for Ruby < 1.9.3 and Rails < 4.0. 158 | 159 | ## 4.0.10.1 (2013-08-20) 160 | 161 | * Update dependencies in gemspec to avoid using with Active Record 4. 162 | * Fixed links in docs. 163 | 164 | ## 4.0.10 (2013-08-10) 165 | 166 | * Fixed table prefixes/suffixes being ignored (Jesse Farless). 167 | * Fixed sequence generation for slugs containing numbers (Adam Carroll). 168 | 169 | ## 4.0.9 (2012-10-31) 170 | 171 | * Fixed support for Rails 3.2.9.rc1 172 | 173 | ## 4.0.8 (2012-08-01) 174 | 175 | * Name internal anonymous class to fix marshall dump/load error (Jess Brown, Philip Arndt and Norman Clarke). 176 | 177 | * Avoid using deprecated `update_attribute` (Philip Arndt). 178 | 179 | * Added set_friendly_id method to Globalize module (Norman Clarke). 180 | 181 | * autoload FriendlyId::Slug; previously this class was not accessible from 182 | migrations unless required explicitly, which could cause some queries to 183 | unexpectedly fail (Norman Clarke). 184 | 185 | * Fix Mocha load order (Mark Turner). 186 | 187 | * Minor doc updates (Rob Yurkowski). 188 | 189 | * Other miscellaneous refactorings and doc updates. 190 | 191 | ## 4.0.7 (2012-06-06) 192 | 193 | * to_param just calls super when no friendly id is present, to keep the model's 194 | default behavior. (Andrew White) 195 | 196 | * FriendlyId can now properly sequence slugs that end in numbers even when a 197 | single dash is used as the separator (Tomás Arribas). 198 | 199 | ## 4.0.6 (2012-05-21) 200 | 201 | * Fix nil return value from to_param when save fails because of validation errors (Tomás Arribas) 202 | * Fix incorrect usage of i18n API (Vinicius Ferriani) 203 | * Improve error handling in reserved module (Adrián Mugnolo and Github user "nolamesa") 204 | 205 | ## 4.0.5 (2012-04-28) 206 | 207 | * Favor `includes` over `joins` in globalize to avoid read-only results (Jakub Wojtysiak) 208 | * Fix globalize compatibility with results from dynamic finders (Chris Salzberg) 209 | 210 | 211 | ## 4.0.4 (2012-03-26) 212 | 213 | * Fix globalize plugin to avoid issues with asset precompilation (Philip Arndt) 214 | 215 | 216 | ## 4.0.3 (2012-03-14) 217 | 218 | * Fix escape for '%' and '_' on SQLite (Norman Clarke and Sergey Petrunin) 219 | * Allow FriendlyId to be extended or included (Norman Clarke) 220 | * Allow Configuration#use to accept a Module (Norman Clarke) 221 | * Fix bugs with History module + STI (Norman Clarke and Sergey Petrunin) 222 | 223 | ## 4.0.2 (2012-03-12) 224 | 225 | * Improved conflict handling and performance in History module (Erik Ogan and Thomas Shafer) 226 | * Fixed bug that impeded using underscores as a sequence separator (Erik Ogan and Thomas Shafer) 227 | * Minor documentation improvements (Norman Clarke) 228 | 229 | ## 4.0.1 (2012-02-29) 230 | 231 | * Added support for Globalize 3 (Enrico Pilotto and Philip Arndt) 232 | * Allow the scoped module to use multiple scopes (Ben Caldwell) 233 | * Fixes for conflicting slugs in history module (Erik Ogan, Thomas Shafer, Evan Arnold) 234 | * Fix for conflicting slugs when using STI (Danny van der Heiden, Diederick Lawson) 235 | * Maintainence improvements (Norman Clarke, Philip Arndt, Thomas Darde, Lee Hambley) 236 | 237 | ## 4.0.0 (2011-12-27) 238 | 239 | This is a complete rewrite of FriendlyId, and introduces a smaller, faster and 240 | less ambitious codebase. The primary change is the relegation of external slugs 241 | to an optional addon, and the adoption of what were formerly "cached slugs" 242 | as the primary way of handling slugging. 243 | 244 | ## Older releases 245 | 246 | Please see the 3.x branch. 247 | -------------------------------------------------------------------------------- /test/history_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | class HistoryTest < TestCaseClass 4 | 5 | include FriendlyId::Test 6 | include FriendlyId::Test::Shared::Core 7 | 8 | class Manual < ActiveRecord::Base 9 | extend FriendlyId 10 | friendly_id :name, :use => [:slugged, :history] 11 | end 12 | 13 | def model_class 14 | Manual 15 | end 16 | 17 | test "should insert record in slugs table on create" do 18 | with_instance_of(model_class) {|record| assert record.slugs.any?} 19 | end 20 | 21 | test "should not create new slug record if friendly_id is not changed" do 22 | with_instance_of(model_class) do |record| 23 | record.active = true 24 | record.save! 25 | assert_equal 1, FriendlyId::Slug.count 26 | end 27 | end 28 | 29 | test "should create new slug record when friendly_id changes" do 30 | with_instance_of(model_class) do |record| 31 | record.name = record.name + "b" 32 | record.slug = nil 33 | record.save! 34 | assert_equal 2, FriendlyId::Slug.count 35 | end 36 | end 37 | 38 | test "should be findable by old slugs" do 39 | with_instance_of(model_class) do |record| 40 | old_friendly_id = record.friendly_id 41 | record.name = record.name + "b" 42 | record.slug = nil 43 | record.save! 44 | begin 45 | assert model_class.friendly.find(old_friendly_id) 46 | assert model_class.friendly.exists?(old_friendly_id), "should exist? by old id" 47 | rescue ActiveRecord::RecordNotFound 48 | flunk "Could not find record by old id" 49 | end 50 | end 51 | end 52 | 53 | test "should create slug records on each change" do 54 | transaction do 55 | record = model_class.create! :name => "hello" 56 | assert_equal 1, FriendlyId::Slug.count 57 | record = model_class.friendly.find("hello") 58 | record.name = "hello again" 59 | record.slug = nil 60 | record.save! 61 | assert_equal 2, FriendlyId::Slug.count 62 | end 63 | end 64 | 65 | test "should not be read only when found by slug" do 66 | with_instance_of(model_class) do |record| 67 | refute model_class.friendly.find(record.friendly_id).readonly? 68 | assert record.update name: 'foo' 69 | end 70 | end 71 | 72 | test "should not be read only when found by old slug" do 73 | with_instance_of(model_class) do |record| 74 | old_friendly_id = record.friendly_id 75 | record.name = record.name + "b" 76 | record.save! 77 | assert !model_class.friendly.find(old_friendly_id).readonly? 78 | end 79 | end 80 | 81 | test "should handle renames" do 82 | with_instance_of(model_class) do |record| 83 | record.name = 'x' 84 | record.slug = nil 85 | assert record.save 86 | record.name = 'y' 87 | record.slug = nil 88 | assert record.save 89 | record.name = 'x' 90 | record.slug = nil 91 | assert record.save 92 | end 93 | end 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 | 117 | test "should not create new slugs that match old slugs" do 118 | transaction do 119 | first_record = model_class.create! :name => "foo" 120 | first_record.name = "bar" 121 | first_record.save! 122 | second_record = model_class.create! :name => "foo" 123 | assert second_record.slug != "foo" 124 | assert_match(/foo-.+/, second_record.slug) 125 | end 126 | end 127 | 128 | test 'should not fail when updating historic slugs' do 129 | transaction do 130 | first_record = model_class.create! :name => "foo" 131 | second_record = model_class.create! :name => 'another' 132 | 133 | second_record.update :name => 'foo', :slug => nil 134 | assert_match(/foo-.*/, second_record.slug) 135 | 136 | first_record.update :name => 'another', :slug => nil 137 | assert_match(/another-.*/, first_record.slug) 138 | end 139 | end 140 | 141 | test "should prefer product that used slug most recently" do 142 | transaction do 143 | first_record = model_class.create! name: "foo" 144 | second_record = model_class.create! name: "bar" 145 | 146 | first_record.update! slug: "not_foo" 147 | second_record.update! slug: "foo" #now both records have used foo; second_record most recently 148 | second_record.update! slug: "not_bar" 149 | 150 | assert_equal model_class.friendly.find("foo"), second_record 151 | end 152 | end 153 | 154 | test 'should name table according to prefix and suffix' do 155 | transaction do 156 | begin 157 | prefix = "prefix_" 158 | without_prefix = FriendlyId::Slug.table_name 159 | ActiveRecord::Base.table_name_prefix = prefix 160 | FriendlyId::Slug.reset_table_name 161 | assert_equal prefix + without_prefix, FriendlyId::Slug.table_name 162 | ensure 163 | ActiveRecord::Base.table_name_prefix = "" 164 | FriendlyId::Slug.table_name = without_prefix 165 | end 166 | end 167 | end 168 | end 169 | 170 | class HistoryTestWithAutomaticSlugRegeneration < HistoryTest 171 | class Manual < ActiveRecord::Base 172 | extend FriendlyId 173 | friendly_id :name, :use => [:slugged, :history] 174 | 175 | def should_generate_new_friendly_id? 176 | slug.blank? or name_changed? 177 | end 178 | end 179 | 180 | def model_class 181 | Manual 182 | end 183 | 184 | test 'should allow reversion back to a previously used slug' do 185 | with_instance_of(model_class, name: 'foo') do |record| 186 | record.name = 'bar' 187 | record.save! 188 | assert_equal 'bar', record.friendly_id 189 | record.name = 'foo' 190 | record.save! 191 | assert_equal 'foo', record.friendly_id 192 | end 193 | end 194 | end 195 | 196 | class DependentDestroyTest < TestCaseClass 197 | 198 | include FriendlyId::Test 199 | 200 | class FalseManual < ActiveRecord::Base 201 | self.table_name = 'manuals' 202 | 203 | extend FriendlyId 204 | friendly_id :name, :use => :history, :dependent => false 205 | end 206 | 207 | class DefaultManual < ActiveRecord::Base 208 | self.table_name = 'manuals' 209 | 210 | extend FriendlyId 211 | friendly_id :name, :use => :history 212 | end 213 | 214 | test 'should allow disabling of dependent destroy' do 215 | transaction do 216 | assert FriendlyId::Slug.find_by_slug('foo').nil? 217 | l = FalseManual.create! :name => 'foo' 218 | assert FriendlyId::Slug.find_by_slug('foo').present? 219 | l.destroy 220 | assert FriendlyId::Slug.find_by_slug('foo').present? 221 | end 222 | end 223 | 224 | test 'should dependently destroy by default' do 225 | transaction do 226 | assert FriendlyId::Slug.find_by_slug('baz').nil? 227 | l = DefaultManual.create! :name => 'baz' 228 | assert FriendlyId::Slug.find_by_slug('baz').present? 229 | l.destroy 230 | assert FriendlyId::Slug.find_by_slug('baz').nil? 231 | end 232 | end 233 | end 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 | 266 | class HistoryTestWithSti < HistoryTest 267 | class Journalist < ActiveRecord::Base 268 | extend FriendlyId 269 | friendly_id :name, :use => [:slugged, :history] 270 | end 271 | 272 | class Editorialist < Journalist 273 | end 274 | 275 | def model_class 276 | Editorialist 277 | end 278 | end 279 | 280 | class HistoryTestWithFriendlyFinders < HistoryTest 281 | class Journalist < ActiveRecord::Base 282 | extend FriendlyId 283 | friendly_id :name, :use => [:slugged, :finders, :history] 284 | end 285 | 286 | class Restaurant < ActiveRecord::Base 287 | extend FriendlyId 288 | belongs_to :city 289 | friendly_id :name, :use => [:slugged, :history, :finders] 290 | end 291 | 292 | 293 | test "should be findable by old slugs" do 294 | [Journalist, Restaurant].each do |model_class| 295 | with_instance_of(model_class) do |record| 296 | old_friendly_id = record.friendly_id 297 | record.name = record.name + "b" 298 | record.slug = nil 299 | record.save! 300 | begin 301 | assert model_class.find(old_friendly_id) 302 | assert model_class.exists?(old_friendly_id), "should exist? by old id for #{model_class.name}" 303 | rescue ActiveRecord::RecordNotFound 304 | flunk "Could not find record by old id for #{model_class.name}" 305 | end 306 | end 307 | end 308 | end 309 | end 310 | 311 | class HistoryTestWithFindersBeforeHistory < HistoryTest 312 | class Novelist < ActiveRecord::Base 313 | has_many :novels 314 | end 315 | 316 | class Novel < ActiveRecord::Base 317 | extend FriendlyId 318 | 319 | belongs_to :novelist 320 | 321 | friendly_id :name, :use => [:finders, :history] 322 | 323 | def should_generate_new_friendly_id? 324 | slug.blank? || name_changed? 325 | end 326 | end 327 | 328 | test "should be findable by old slug through has_many association" do 329 | transaction do 330 | novelist = Novelist.create!(:name => "Stephen King") 331 | novel = novelist.novels.create(:name => "Rita Hayworth and Shawshank Redemption") 332 | slug = novel.slug 333 | novel.name = "Shawshank Redemption" 334 | novel.save! 335 | assert_equal novel, Novel.find(slug) 336 | assert_equal novel, novelist.novels.find(slug) 337 | end 338 | end 339 | end 340 | 341 | class City < ActiveRecord::Base 342 | has_many :restaurants 343 | end 344 | 345 | class Restaurant < ActiveRecord::Base 346 | extend FriendlyId 347 | belongs_to :city 348 | friendly_id :name, :use => [:scoped, :history], :scope => :city 349 | end 350 | 351 | class ScopedHistoryTest < TestCaseClass 352 | include FriendlyId::Test 353 | include FriendlyId::Test::Shared::Core 354 | 355 | def model_class 356 | Restaurant 357 | end 358 | 359 | test "should find old scoped slugs" do 360 | transaction do 361 | city = City.create! 362 | with_instance_of(Restaurant) do |record| 363 | record.city = city 364 | 365 | record.name = "x" 366 | record.slug = nil 367 | record.save! 368 | 369 | record.name = "y" 370 | record.slug = nil 371 | record.save! 372 | 373 | assert_equal city.restaurants.friendly.find("x"), city.restaurants.friendly.find("y") 374 | end 375 | end 376 | end 377 | 378 | test "should consider old scoped slugs when creating slugs" do 379 | transaction do 380 | city = City.create! 381 | with_instance_of(Restaurant) do |record| 382 | record.city = city 383 | 384 | record.name = "x" 385 | record.slug = nil 386 | record.save! 387 | 388 | record.name = "y" 389 | record.slug = nil 390 | record.save! 391 | 392 | second_record = model_class.create! :city => city, :name => 'x' 393 | assert_match(/x-.+/, second_record.friendly_id) 394 | 395 | third_record = model_class.create! :city => city, :name => 'y' 396 | assert_match(/y-.+/, third_record.friendly_id) 397 | end 398 | end 399 | end 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 | 428 | test "should allow equal slugs in different scopes" do 429 | transaction do 430 | city = City.create! 431 | second_city = City.create! 432 | record = model_class.create! :city => city, :name => 'x' 433 | second_record = model_class.create! :city => second_city, :name => 'x' 434 | 435 | assert_equal record.slug, second_record.slug 436 | end 437 | end 438 | end 439 | -------------------------------------------------------------------------------- /lib/friendly_id/slugged.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require "friendly_id/slug_generator" 3 | require "friendly_id/candidates" 4 | 5 | module FriendlyId 6 | =begin 7 | 8 | ## Slugged Models 9 | 10 | FriendlyId can use a separate column to store slugs for models which require 11 | some text processing. 12 | 13 | For example, blog applications typically use a post title to provide the basis 14 | of a search engine friendly URL. Such identifiers typically lack uppercase 15 | characters, use ASCII to approximate UTF-8 characters, and strip out other 16 | characters which may make them aesthetically unappealing or error-prone when 17 | used in a URL. 18 | 19 | class Post < ActiveRecord::Base 20 | extend FriendlyId 21 | friendly_id :title, :use => :slugged 22 | end 23 | 24 | @post = Post.create(:title => "This is the first post!") 25 | @post.friendly_id # returns "this-is-the-first-post" 26 | redirect_to @post # the URL will be /posts/this-is-the-first-post 27 | 28 | In general, use slugs by default unless you know for sure you don't need them. 29 | To activate the slugging functionality, use the {FriendlyId::Slugged} module. 30 | 31 | FriendlyId will generate slugs from a method or column that you specify, and 32 | store them in a field in your model. By default, this field must be named 33 | `:slug`, though you may change this using the 34 | {FriendlyId::Slugged::Configuration#slug_column slug_column} configuration 35 | option. You should add an index to this column, and in most cases, make it 36 | unique. You may also wish to constrain it to NOT NULL, but this depends on your 37 | app's behavior and requirements. 38 | 39 | ### Example Setup 40 | 41 | # your model 42 | class Post < ActiveRecord::Base 43 | extend FriendlyId 44 | friendly_id :title, :use => :slugged 45 | validates_presence_of :title, :slug, :body 46 | end 47 | 48 | # a migration 49 | class CreatePosts < ActiveRecord::Migration 50 | def self.up 51 | create_table :posts do |t| 52 | t.string :title, :null => false 53 | t.string :slug, :null => false 54 | t.text :body 55 | end 56 | 57 | add_index :posts, :slug, :unique => true 58 | end 59 | 60 | def self.down 61 | drop_table :posts 62 | end 63 | end 64 | 65 | ### Working With Slugs 66 | 67 | #### Formatting 68 | 69 | By default, FriendlyId uses Active Support's 70 | [paramaterize](http://api.rubyonrails.org/classes/ActiveSupport/Inflector.html#method-i-parameterize) 71 | method to create slugs. This method will intelligently replace spaces with 72 | dashes, and Unicode Latin characters with ASCII approximations: 73 | 74 | movie = Movie.create! :title => "Der Preis fürs Überleben" 75 | movie.slug #=> "der-preis-furs-uberleben" 76 | 77 | #### Column or Method? 78 | 79 | FriendlyId always uses a method as the basis of the slug text - not a column. At 80 | first glance, this may sound confusing, but remember that Active Record provides 81 | methods for each column in a model's associated table, and that's what 82 | FriendlyId uses. 83 | 84 | Here's an example of a class that uses a custom method to generate the slug: 85 | 86 | class Person < ActiveRecord::Base 87 | extend FriendlyId 88 | friendly_id :name_and_location, use: :slugged 89 | 90 | def name_and_location 91 | "#{name} from #{location}" 92 | end 93 | end 94 | 95 | bob = Person.create! :name => "Bob Smith", :location => "New York City" 96 | bob.friendly_id #=> "bob-smith-from-new-york-city" 97 | 98 | FriendlyId refers to this internally as the "base" method. 99 | 100 | #### Uniqueness 101 | 102 | When you try to insert a record that would generate a duplicate friendly id, 103 | FriendlyId will append a UUID to the generated slug to ensure uniqueness: 104 | 105 | car = Car.create :title => "Peugeot 206" 106 | car2 = Car.create :title => "Peugeot 206" 107 | 108 | car.friendly_id #=> "peugeot-206" 109 | car2.friendly_id #=> "peugeot-206-f9f3789a-daec-4156-af1d-fab81aa16ee5" 110 | 111 | Previous versions of FriendlyId appended a numeric sequence to make slugs 112 | unique, but this was removed to simplify using FriendlyId in concurrent code. 113 | 114 | #### Candidates 115 | 116 | Since UUIDs are ugly, FriendlyId provides a "slug candidates" functionality to 117 | let you specify alternate slugs to use in the event the one you want to use is 118 | already taken. For example: 119 | 120 | class Restaurant < ActiveRecord::Base 121 | extend FriendlyId 122 | friendly_id :slug_candidates, use: :slugged 123 | 124 | # Try building a slug based on the following fields in 125 | # increasing order of specificity. 126 | def slug_candidates 127 | [ 128 | :name, 129 | [:name, :city], 130 | [:name, :street, :city], 131 | [:name, :street_number, :street, :city] 132 | ] 133 | end 134 | end 135 | 136 | r1 = Restaurant.create! name: 'Plaza Diner', city: 'New Paltz' 137 | r2 = Restaurant.create! name: 'Plaza Diner', city: 'Kingston' 138 | 139 | r1.friendly_id #=> 'plaza-diner' 140 | r2.friendly_id #=> 'plaza-diner-kingston' 141 | 142 | To use candidates, make your FriendlyId base method return an array. The 143 | method need not be named `slug_candidates`; it can be anything you want. The 144 | array may contain any combination of symbols, strings, procs or lambdas and 145 | will be evaluated lazily and in order. If you include symbols, FriendlyId will 146 | invoke a method on your model class with the same name. Strings will be 147 | interpreted literally. Procs and lambdas will be called and their return values 148 | used as the basis of the friendly id. If none of the candidates can generate a 149 | unique slug, then FriendlyId will append a UUID to the first candidate as a 150 | last resort. 151 | 152 | #### Sequence Separator 153 | 154 | By default, FriendlyId uses a dash to separate the slug from a sequence. 155 | 156 | You can change this with the {FriendlyId::Slugged::Configuration#sequence_separator 157 | sequence_separator} configuration option. 158 | 159 | #### Providing Your Own Slug Processing Method 160 | 161 | You can override {FriendlyId::Slugged#normalize_friendly_id} in your model for 162 | total control over the slug format. It will be invoked for any generated slug, 163 | whether for a single slug or for slug candidates. 164 | 165 | #### Deciding When to Generate New Slugs 166 | 167 | As of FriendlyId 5.0, slugs are only generated when the `slug` field is nil. If 168 | you want a slug to be regenerated,set the slug field to nil: 169 | 170 | restaurant.friendly_id # joes-diner 171 | restaurant.name = "The Plaza Diner" 172 | restaurant.save! 173 | restaurant.friendly_id # joes-diner 174 | restaurant.slug = nil 175 | restaurant.save! 176 | restaurant.friendly_id # the-plaza-diner 177 | 178 | You can also override the 179 | {FriendlyId::Slugged#should_generate_new_friendly_id?} method, which lets you 180 | control exactly when new friendly ids are set: 181 | 182 | class Post < ActiveRecord::Base 183 | extend FriendlyId 184 | friendly_id :title, :use => :slugged 185 | 186 | def should_generate_new_friendly_id? 187 | title_changed? 188 | end 189 | end 190 | 191 | If you want to extend the default behavior but add your own conditions, 192 | don't forget to invoke `super` from your implementation: 193 | 194 | class Category < ActiveRecord::Base 195 | extend FriendlyId 196 | friendly_id :name, :use => :slugged 197 | 198 | def should_generate_new_friendly_id? 199 | name_changed? || super 200 | end 201 | end 202 | 203 | #### Locale-specific Transliterations 204 | 205 | Active Support's `parameterize` uses 206 | [transliterate](http://api.rubyonrails.org/classes/ActiveSupport/Inflector.html#method-i-transliterate), 207 | which in turn can use I18n's transliteration rules to consider the current 208 | locale when replacing Latin characters: 209 | 210 | # config/locales/de.yml 211 | de: 212 | i18n: 213 | transliterate: 214 | rule: 215 | ü: "ue" 216 | ö: "oe" 217 | etc... 218 | 219 | movie = Movie.create! :title => "Der Preis fürs Überleben" 220 | movie.slug #=> "der-preis-fuers-ueberleben" 221 | 222 | This functionality was in fact taken from earlier versions of FriendlyId. 223 | 224 | #### Gotchas: Common Problems 225 | 226 | FriendlyId uses a before_validation callback to generate and set the slug. This 227 | means that if you create two model instances before saving them, it's possible 228 | they will generate the same slug, and the second save will fail. 229 | 230 | This can happen in two fairly normal cases: the first, when a model using nested 231 | attributes creates more than one record for a model that uses friendly_id. The 232 | second, in concurrent code, either in threads or multiple processes. 233 | 234 | To solve the nested attributes issue, I recommend simply avoiding them when 235 | creating more than one nested record for a model that uses FriendlyId. See [this 236 | Github issue](https://github.com/norman/friendly_id/issues/185) for discussion. 237 | 238 | =end 239 | module Slugged 240 | 241 | # Sets up behavior and configuration options for FriendlyId's slugging 242 | # feature. 243 | def self.included(model_class) 244 | model_class.friendly_id_config.instance_eval do 245 | self.class.send :include, Configuration 246 | self.slug_generator_class ||= SlugGenerator 247 | defaults[:slug_column] ||= 'slug' 248 | defaults[:sequence_separator] ||= '-' 249 | end 250 | model_class.before_validation :set_slug 251 | model_class.after_validation :unset_slug_if_invalid 252 | end 253 | 254 | # Process the given value to make it suitable for use as a slug. 255 | # 256 | # This method is not intended to be invoked directly; FriendlyId uses it 257 | # internally to process strings into slugs. 258 | # 259 | # However, if FriendlyId's default slug generation doesn't suit your needs, 260 | # you can override this method in your model class to control exactly how 261 | # slugs are generated. 262 | # 263 | # ### Example 264 | # 265 | # class Person < ActiveRecord::Base 266 | # extend FriendlyId 267 | # friendly_id :name_and_location 268 | # 269 | # def name_and_location 270 | # "#{name} from #{location}" 271 | # end 272 | # 273 | # # Use default slug, but upper case and with underscores 274 | # def normalize_friendly_id(string) 275 | # super.upcase.gsub("-", "_") 276 | # end 277 | # end 278 | # 279 | # bob = Person.create! :name => "Bob Smith", :location => "New York City" 280 | # bob.friendly_id #=> "BOB_SMITH_FROM_NEW_YORK_CITY" 281 | # 282 | # ### More Resources 283 | # 284 | # You might want to look into Babosa[https://github.com/norman/babosa], 285 | # which is the slugging library used by FriendlyId prior to version 4, which 286 | # offers some specialized functionality missing from Active Support. 287 | # 288 | # @param [#to_s] value The value used as the basis of the slug. 289 | # @return The candidate slug text, without a sequence. 290 | def normalize_friendly_id(value) 291 | value = value.to_s.parameterize 292 | value = value[0...friendly_id_config.slug_limit] if friendly_id_config.slug_limit 293 | value 294 | end 295 | 296 | # Whether to generate a new slug. 297 | # 298 | # You can override this method in your model if, for example, you only want 299 | # slugs to be generated once, and then never updated. 300 | def should_generate_new_friendly_id? 301 | send(friendly_id_config.slug_column).nil? && !send(friendly_id_config.base).nil? 302 | end 303 | 304 | # Public: Resolve conflicts. 305 | # 306 | # This method adds UUID to first candidate and truncates (if `slug_limit` is set). 307 | # 308 | # Examples: 309 | # 310 | # resolve_friendly_id_conflict(['12345']) 311 | # # => '12345-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' 312 | # 313 | # FriendlyId.defaults { |config| config.slug_limit = 40 } 314 | # resolve_friendly_id_conflict(['12345']) 315 | # # => '123-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' 316 | # 317 | # candidates - the Array with candidates. 318 | # 319 | # Returns the String with new slug. 320 | def resolve_friendly_id_conflict(candidates) 321 | uuid = SecureRandom.uuid 322 | [ 323 | apply_slug_limit(candidates.first, uuid), 324 | uuid 325 | ].compact.join(friendly_id_config.sequence_separator) 326 | end 327 | 328 | # Private: Apply slug limit to candidate. 329 | # 330 | # candidate - the String with candidate. 331 | # uuid - the String with UUID. 332 | # 333 | # Return the String with truncated candidate. 334 | def apply_slug_limit(candidate, uuid) 335 | return candidate unless candidate && friendly_id_config.slug_limit 336 | 337 | candidate[0...candidate_limit(uuid)] 338 | end 339 | private :apply_slug_limit 340 | 341 | # Private: Get max length of candidate. 342 | # 343 | # uuid - the String with UUID. 344 | # 345 | # Returns the Integer with max length. 346 | def candidate_limit(uuid) 347 | [ 348 | friendly_id_config.slug_limit - uuid.size - friendly_id_config.sequence_separator.size, 349 | 0 350 | ].max 351 | end 352 | private :candidate_limit 353 | 354 | # Sets the slug. 355 | def set_slug(normalized_slug = nil) 356 | if should_generate_new_friendly_id? 357 | candidates = FriendlyId::Candidates.new(self, normalized_slug || send(friendly_id_config.base)) 358 | slug = slug_generator.generate(candidates) || resolve_friendly_id_conflict(candidates) 359 | send "#{friendly_id_config.slug_column}=", slug 360 | end 361 | end 362 | private :set_slug 363 | 364 | def scope_for_slug_generator 365 | scope = self.class.base_class.unscoped 366 | scope = scope.friendly unless scope.respond_to?(:exists_by_friendly_id?) 367 | primary_key_name = self.class.primary_key 368 | scope.where(self.class.base_class.arel_table[primary_key_name].not_eq(send(primary_key_name))) 369 | end 370 | private :scope_for_slug_generator 371 | 372 | def slug_generator 373 | friendly_id_config.slug_generator_class.new(scope_for_slug_generator, friendly_id_config) 374 | end 375 | private :slug_generator 376 | 377 | def unset_slug_if_invalid 378 | if errors.present? && attribute_changed?(friendly_id_config.query_field.to_s) 379 | diff = changes[friendly_id_config.query_field] 380 | send "#{friendly_id_config.slug_column}=", diff.first 381 | end 382 | end 383 | private :unset_slug_if_invalid 384 | 385 | # This module adds the `:slug_column`, and `:slug_limit`, and `:sequence_separator`, 386 | # and `:slug_generator_class` configuration options to 387 | # {FriendlyId::Configuration FriendlyId::Configuration}. 388 | module Configuration 389 | attr_writer :slug_column, :slug_limit, :sequence_separator 390 | attr_accessor :slug_generator_class 391 | 392 | # Makes FriendlyId use the slug column for querying. 393 | # @return String The slug column. 394 | def query_field 395 | slug_column 396 | end 397 | 398 | # The string used to separate a slug base from a numeric sequence. 399 | # 400 | # You can change the default separator by setting the 401 | # {FriendlyId::Slugged::Configuration#sequence_separator 402 | # sequence_separator} configuration option. 403 | # @return String The sequence separator string. Defaults to "`-`". 404 | def sequence_separator 405 | @sequence_separator ||= defaults[:sequence_separator] 406 | end 407 | 408 | # The column that will be used to store the generated slug. 409 | def slug_column 410 | @slug_column ||= defaults[:slug_column] 411 | end 412 | 413 | # The limit that will be used for slug. 414 | def slug_limit 415 | @slug_limit ||= defaults[:slug_limit] 416 | end 417 | end 418 | end 419 | end 420 | -------------------------------------------------------------------------------- /test/slugged_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | class Journalist < ActiveRecord::Base 4 | extend FriendlyId 5 | friendly_id :name, :use => :slugged 6 | end 7 | 8 | class Article < ActiveRecord::Base 9 | extend FriendlyId 10 | friendly_id :name, :use => :slugged 11 | end 12 | 13 | class Novelist < ActiveRecord::Base 14 | extend FriendlyId 15 | friendly_id :name, :use => :slugged, :sequence_separator => '_' 16 | 17 | def normalize_friendly_id(string) 18 | super.gsub("-", "_") 19 | end 20 | end 21 | 22 | class SluggedTest < TestCaseClass 23 | 24 | include FriendlyId::Test 25 | include FriendlyId::Test::Shared::Core 26 | include FriendlyId::Test::Shared::Slugged 27 | 28 | def model_class 29 | Journalist 30 | end 31 | 32 | test "should allow validations on the slug" do 33 | model_class = Class.new(ActiveRecord::Base) do 34 | self.table_name = "articles" 35 | extend FriendlyId 36 | friendly_id :name, :use => :slugged 37 | validates_length_of :slug, :maximum => 1 38 | def self.name 39 | "Article" 40 | end 41 | end 42 | instance = model_class.new :name => "hello" 43 | refute instance.valid? 44 | end 45 | 46 | test "should allow nil slugs" do 47 | transaction do 48 | m1 = model_class.create! 49 | model_class.create! 50 | assert_nil m1.slug 51 | end 52 | end 53 | 54 | test "should not break validates_uniqueness_of" do 55 | model_class = Class.new(ActiveRecord::Base) do 56 | self.table_name = "journalists" 57 | extend FriendlyId 58 | friendly_id :name, :use => :slugged 59 | validates_uniqueness_of :slug_en 60 | def self.name 61 | "Journalist" 62 | end 63 | end 64 | transaction do 65 | instance = model_class.create! :name => "hello", :slug_en => "hello" 66 | instance2 = model_class.create :name => "hello", :slug_en => "hello" 67 | assert instance.valid? 68 | refute instance2.valid? 69 | end 70 | end 71 | 72 | test 'should allow a record to reuse its own slug' do 73 | with_instance_of(model_class) do |record| 74 | old_id = record.friendly_id 75 | record.slug = nil 76 | record.save! 77 | assert_equal old_id, record.friendly_id 78 | end 79 | end 80 | 81 | test "should not update matching slug" do 82 | klass = Class.new model_class do 83 | def should_generate_new_friendly_id? 84 | name_changed? 85 | end 86 | end 87 | with_instance_of klass do |record| 88 | old_id = record.friendly_id 89 | record.name += " " 90 | record.save! 91 | assert_equal old_id, record.friendly_id 92 | end 93 | end 94 | 95 | test "should not set slug on create if unrelated validations fail" do 96 | klass = Class.new model_class do 97 | validates_presence_of :active 98 | friendly_id :name, :use => :slugged 99 | 100 | def self.name 101 | "Journalist" 102 | end 103 | end 104 | 105 | transaction do 106 | instance = klass.new :name => 'foo' 107 | refute instance.save 108 | refute instance.valid? 109 | assert_nil instance.slug 110 | end 111 | end 112 | 113 | test "should not set slug on create if unrelated validations fail with custom slug_column" do 114 | klass = Class.new(ActiveRecord::Base) do 115 | self.table_name = 'authors' 116 | extend FriendlyId 117 | validates_presence_of :active 118 | friendly_id :name, :use => :slugged, :slug_column => :subdomain 119 | 120 | def self.name 121 | "Author" 122 | end 123 | end 124 | 125 | transaction do 126 | instance = klass.new :name => 'foo' 127 | refute instance.save 128 | refute instance.valid? 129 | assert_nil instance.subdomain 130 | end 131 | end 132 | 133 | test "should not update slug on save if unrelated validations fail" do 134 | klass = Class.new model_class do 135 | validates_presence_of :active 136 | friendly_id :name, :use => :slugged 137 | 138 | def self.name 139 | "Journalist" 140 | end 141 | end 142 | 143 | transaction do 144 | instance = klass.new :name => 'foo', :active => true 145 | assert instance.save 146 | assert instance.valid? 147 | instance.name = 'foobar' 148 | instance.slug = nil 149 | instance.active = nil 150 | refute instance.save 151 | refute instance.valid? 152 | assert_equal 'foo', instance.slug 153 | end 154 | end 155 | end 156 | 157 | class SlugGeneratorTest < TestCaseClass 158 | 159 | include FriendlyId::Test 160 | 161 | def model_class 162 | Journalist 163 | end 164 | 165 | test "should quote column names" do 166 | model_class = Class.new(ActiveRecord::Base) do 167 | # This has been added in 635731bb to fix MySQL/Rubinius. It may still 168 | # be necessary, but causes an exception to be raised on Rails 4, so I'm 169 | # commenting it out. If it causes MySQL/Rubinius to fail again we'll 170 | # look for another solution. 171 | # self.abstract_class = true 172 | self.table_name = "journalists" 173 | extend FriendlyId 174 | friendly_id :name, :use => :slugged, :slug_column => "strange name" 175 | end 176 | 177 | begin 178 | with_instance_of(model_class) {|record| assert model_class.friendly.find(record.friendly_id)} 179 | rescue ActiveRecord::StatementInvalid 180 | flunk "column name was not quoted" 181 | end 182 | end 183 | 184 | test "should not resequence lower sequences on update" do 185 | transaction do 186 | m1 = model_class.create! :name => "a b c d" 187 | assert_equal "a-b-c-d", m1.slug 188 | model_class.create! :name => "a b c d" 189 | m1 = model_class.friendly.find(m1.id) 190 | m1.save! 191 | assert_equal "a-b-c-d", m1.slug 192 | end 193 | end 194 | 195 | test "should correctly sequence slugs that end with numbers" do 196 | transaction do 197 | record1 = model_class.create! :name => "Peugeot 206" 198 | assert_equal "peugeot-206", record1.slug 199 | record2 = model_class.create! :name => "Peugeot 206" 200 | assert_match(/\Apeugeot-206-([a-z0-9]+\-){4}[a-z0-9]+\z/, record2.slug) 201 | end 202 | end 203 | 204 | test "should correctly sequence slugs with underscores" do 205 | transaction do 206 | Novelist.create! :name => 'wordsfail, buildings tumble' 207 | record2 = Novelist.create! :name => 'word fail' 208 | assert_equal 'word_fail', record2.slug 209 | end 210 | end 211 | 212 | test "should correctly sequence numeric slugs" do 213 | transaction do 214 | n2 = 2.times.map {Article.create :name => '123'}.last 215 | assert_match(/\A123-.*/, n2.friendly_id) 216 | end 217 | end 218 | 219 | end 220 | 221 | class SlugSeparatorTest < TestCaseClass 222 | 223 | include FriendlyId::Test 224 | 225 | class Journalist < ActiveRecord::Base 226 | extend FriendlyId 227 | friendly_id :name, :use => :slugged, :sequence_separator => ":" 228 | end 229 | 230 | def model_class 231 | Journalist 232 | end 233 | 234 | test "should sequence with configured sequence separator" do 235 | with_instance_of model_class do |record| 236 | record2 = model_class.create! :name => record.name 237 | assert record2.friendly_id.match(/:.*\z/) 238 | end 239 | end 240 | 241 | test "should detect when a stored slug has been cleared" do 242 | with_instance_of model_class do |record| 243 | record.slug = nil 244 | assert record.should_generate_new_friendly_id? 245 | end 246 | end 247 | 248 | test "should correctly sequence slugs that uses single dashes as sequence separator" do 249 | model_class = Class.new(ActiveRecord::Base) do 250 | self.table_name = "journalists" 251 | extend FriendlyId 252 | friendly_id :name, :use => :slugged, :sequence_separator => '-' 253 | def self.name 254 | "Journalist" 255 | end 256 | end 257 | transaction do 258 | record1 = model_class.create! :name => "Peugeot 206" 259 | assert_equal "peugeot-206", record1.slug 260 | record2 = model_class.create! :name => "Peugeot 206" 261 | assert_match(/\Apeugeot-206-([a-z0-9]+\-){4}[a-z0-9]+\z/, record2.slug) 262 | end 263 | end 264 | 265 | test "should sequence blank slugs without a separator" do 266 | with_instance_of model_class, :name => "" do |record| 267 | assert_match(/\A([a-z0-9]+\-){4}[a-z0-9]+\z/, record.slug) 268 | end 269 | end 270 | 271 | end 272 | 273 | class SlugLimitTest < TestCaseClass 274 | 275 | include FriendlyId::Test 276 | 277 | class Journalist < ActiveRecord::Base 278 | extend FriendlyId 279 | friendly_id :name, :use => :slugged, :slug_limit => 40 280 | end 281 | 282 | def model_class 283 | Journalist 284 | end 285 | 286 | test "should limit slug size" do 287 | transaction do 288 | m1 = model_class.create! :name => 'a' * 50 289 | assert_equal m1.slug, 'a' * 40 290 | m2 = model_class.create! :name => m1.name 291 | m2.save! 292 | # "aaa-" 293 | assert_match(/\Aa{3}\-/, m2.slug) 294 | end 295 | end 296 | end 297 | 298 | class DefaultScopeTest < TestCaseClass 299 | 300 | include FriendlyId::Test 301 | 302 | class Journalist < ActiveRecord::Base 303 | extend FriendlyId 304 | friendly_id :name, :use => :slugged 305 | default_scope -> { where(:active => true).order('id ASC') } 306 | end 307 | 308 | test "friendly_id should correctly sequence a default_scoped ordered table" do 309 | transaction do 310 | 3.times { assert Journalist.create :name => "a", :active => true } 311 | end 312 | end 313 | 314 | test "friendly_id should correctly sequence a default_scoped scoped table" do 315 | transaction do 316 | assert Journalist.create :name => "a", :active => false 317 | assert Journalist.create :name => "a", :active => true 318 | end 319 | end 320 | 321 | end 322 | 323 | class UuidAsPrimaryKeyFindTest < TestCaseClass 324 | 325 | include FriendlyId::Test 326 | 327 | class MenuItem < ActiveRecord::Base 328 | extend FriendlyId 329 | friendly_id :name, :use => :slugged 330 | before_create :init_primary_key 331 | 332 | def self.primary_key 333 | "uuid_key" 334 | end 335 | 336 | # Overwrite the method added by FriendlyId 337 | def self.primary_key_type 338 | :uuid 339 | end 340 | 341 | private 342 | def init_primary_key 343 | self.uuid_key = SecureRandom.uuid 344 | end 345 | end 346 | 347 | def model_class 348 | MenuItem 349 | end 350 | 351 | test "should have a uuid_key as a primary key" do 352 | assert_equal "uuid_key", model_class.primary_key 353 | assert_equal :uuid, model_class.primary_key_type 354 | end 355 | 356 | test "should be findable by the UUID primary key" do 357 | with_instance_of(model_class) do |record| 358 | assert model_class.friendly.find record.id 359 | end 360 | end 361 | 362 | test "should handle a string that simply contains a UUID correctly" do 363 | with_instance_of(model_class) do |record| 364 | assert_raises(ActiveRecord::RecordNotFound) do 365 | model_class.friendly.find "test-#{SecureRandom.uuid}" 366 | end 367 | end 368 | end 369 | 370 | end 371 | 372 | class UnderscoreAsSequenceSeparatorRegressionTest < TestCaseClass 373 | 374 | include FriendlyId::Test 375 | 376 | class Manual < ActiveRecord::Base 377 | extend FriendlyId 378 | friendly_id :name, :use => :slugged, :sequence_separator => "_" 379 | end 380 | 381 | test "should not create duplicate slugs" do 382 | 3.times do 383 | transaction do 384 | begin 385 | assert Manual.create! :name => "foo" 386 | rescue 387 | flunk "Tried to insert duplicate slug" 388 | end 389 | end 390 | end 391 | end 392 | 393 | end 394 | 395 | # https://github.com/norman/friendly_id/issues/148 396 | class FailedValidationAfterUpdateRegressionTest < TestCaseClass 397 | 398 | include FriendlyId::Test 399 | 400 | class Journalist < ActiveRecord::Base 401 | extend FriendlyId 402 | friendly_id :name, :use => :slugged 403 | validates_presence_of :slug_de 404 | end 405 | 406 | test "to_param should return the unchanged value if the slug changes before validation fails" do 407 | transaction do 408 | journalist = Journalist.create! :name => "Joseph Pulitzer", :slug_de => "value" 409 | assert_equal "joseph-pulitzer", journalist.to_param 410 | assert journalist.valid? 411 | assert journalist.persisted? 412 | journalist.name = "Joe Pulitzer" 413 | journalist.slug_de = nil 414 | assert !journalist.valid? 415 | assert_equal "joseph-pulitzer", journalist.to_param 416 | end 417 | end 418 | 419 | end 420 | 421 | class ToParamTest < TestCaseClass 422 | 423 | include FriendlyId::Test 424 | 425 | class Journalist < ActiveRecord::Base 426 | extend FriendlyId 427 | validates_presence_of :active 428 | friendly_id :name, :use => :slugged 429 | 430 | attr_accessor :to_param_in_callback 431 | 432 | after_save do 433 | self.to_param_in_callback = to_param 434 | end 435 | end 436 | 437 | test "to_param should return nil if record is unpersisted" do 438 | assert_nil Journalist.new.to_param 439 | end 440 | 441 | test "to_param should return nil if record failed validation" do 442 | journalist = Journalist.new :name => 'Clark Kent', :active => nil 443 | refute journalist.save 444 | assert_nil journalist.to_param 445 | end 446 | 447 | test "to_param should use slugged attribute if record saved successfully" do 448 | transaction do 449 | journalist = Journalist.new :name => 'Clark Kent', :active => true 450 | assert journalist.save 451 | assert_equal 'clark-kent', journalist.to_param 452 | end 453 | end 454 | 455 | test "to_param should use original slug if existing record changes but fails to save" do 456 | transaction do 457 | journalist = Journalist.new :name => 'Clark Kent', :active => true 458 | assert journalist.save 459 | journalist.name = 'Superman' 460 | journalist.slug = nil 461 | journalist.active = nil 462 | refute journalist.save 463 | assert_equal 'clark-kent', journalist.to_param 464 | end 465 | end 466 | 467 | test "to_param should use new slug if existing record changes successfully" do 468 | transaction do 469 | journalist = Journalist.new :name => 'Clark Kent', :active => true 470 | assert journalist.save 471 | journalist.name = 'Superman' 472 | journalist.slug = nil 473 | assert journalist.save 474 | assert_equal 'superman', journalist.to_param 475 | end 476 | end 477 | 478 | test "to_param should use new slug within callbacks if new record is saved successfully" do 479 | transaction do 480 | journalist = Journalist.new :name => 'Clark Kent', :active => true 481 | assert journalist.save 482 | assert_equal 'clark-kent', journalist.to_param_in_callback, "value of to_param in callback should use the new slug value" 483 | end 484 | end 485 | 486 | test "to_param should use new slug within callbacks if existing record changes successfully" do 487 | transaction do 488 | journalist = Journalist.new :name => 'Clark Kent', :active => true 489 | assert journalist.save 490 | assert journalist.valid? 491 | journalist.name = 'Superman' 492 | journalist.slug = nil 493 | assert journalist.save, "save should be successful" 494 | assert_equal 'superman', journalist.to_param_in_callback, "value of to_param in callback should use the new slug value" 495 | end 496 | end 497 | 498 | end 499 | 500 | class ConfigurableRoutesTest < TestCaseClass 501 | include FriendlyId::Test 502 | 503 | class Article < ActiveRecord::Base 504 | extend FriendlyId 505 | 506 | friendly_id :name, :use => :slugged, :routes => :friendly 507 | end 508 | 509 | class Novel < ActiveRecord::Base 510 | extend FriendlyId 511 | 512 | friendly_id :name, :use => :slugged, :routes => :default 513 | end 514 | 515 | test "to_param should return a friendly id when the routes option is set to :friendly" do 516 | transaction do 517 | article = Article.create! :name => "Titanic Hits; Iceberg Sinks" 518 | 519 | assert_equal "titanic-hits-iceberg-sinks", article.to_param 520 | end 521 | end 522 | 523 | test "to_param should return the id when the routes option is set to anything but friendly" do 524 | transaction do 525 | novel = Novel.create! :name => "Don Quixote" 526 | 527 | assert_equal novel.id.to_s, novel.to_param 528 | end 529 | end 530 | end 531 | --------------------------------------------------------------------------------