├── .gemtest ├── .github ├── FUNDING.yml ├── dependabot.yml ├── stale.yml └── workflows │ └── test.yml ├── .gitignore ├── .yardopts ├── CONTRIBUTING.md ├── Changelog.md ├── Gemfile ├── MIT-LICENSE ├── README.md ├── Rakefile ├── UPGRADING.md ├── bench.rb ├── certs └── parndt.pem ├── friendly_id.gemspec ├── gemfiles ├── Gemfile.rails-6.0.rb ├── Gemfile.rails-6.1.rb ├── Gemfile.rails-7.0.rb ├── Gemfile.rails-7.1.rb ├── Gemfile.rails-7.2.rb └── Gemfile.rails-8.0.rb ├── guide.rb ├── lib ├── friendly_id.rb ├── friendly_id │ ├── .gitattributes │ ├── base.rb │ ├── candidates.rb │ ├── configuration.rb │ ├── finder_methods.rb │ ├── finders.rb │ ├── history.rb │ ├── initializer.rb │ ├── migration.rb │ ├── object_utils.rb │ ├── reserved.rb │ ├── scoped.rb │ ├── sequentially_slugged.rb │ ├── sequentially_slugged │ │ └── calculator.rb │ ├── simple_i18n.rb │ ├── slug.rb │ ├── slug_generator.rb │ ├── slugged.rb │ └── version.rb └── generators │ └── friendly_id_generator.rb └── test ├── base_test.rb ├── benchmarks ├── finders.rb └── object_utils.rb ├── candidates_test.rb ├── configuration_test.rb ├── core_test.rb ├── databases.yml ├── finders_test.rb ├── generator_test.rb ├── helper.rb ├── history_test.rb ├── numeric_slug_test.rb ├── object_utils_test.rb ├── reserved_test.rb ├── schema.rb ├── scoped_test.rb ├── sequentially_slugged_test.rb ├── shared.rb ├── simple_i18n_test.rb ├── slugged_test.rb └── sti_test.rb /.gemtest: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/norman/friendly_id/c288abb863cc0ad3a57131f1047542969776ecb7/.gemtest -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: parndt 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 84 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 | -------------------------------------------------------------------------------- /.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 | database: [ mysql, postgresql ] 13 | gemfile: [ '8.0', '7.2', '7.1', '7.0', '6.1', '6.0' ] 14 | ruby: [ '2.7', '3.0', '3.1', '3.2', '3.3', '3.4' ] 15 | exclude: 16 | - gemfile: '6.0' 17 | ruby: '3.2' 18 | - gemfile: '6.0' 19 | ruby: '3.3' 20 | - gemfile: '6.0' 21 | ruby: '3.4' 22 | - gemfile: '6.1' 23 | ruby: '3.2' 24 | - gemfile: '6.1' 25 | ruby: '3.3' 26 | - gemfile: '6.1' 27 | ruby: '3.4' 28 | - gemfile: '7.0' 29 | ruby: '3.3' 30 | - gemfile: '7.0' 31 | ruby: '3.4' 32 | - gemfile: '7.1' 33 | ruby: '3.3' 34 | - gemfile: '7.1' 35 | ruby: '3.4' 36 | - gemfile: '7.2' 37 | ruby: '2.7' 38 | - gemfile: '7.2' 39 | ruby: '3.0' 40 | - gemfile: '8.0' 41 | ruby: '2.7' 42 | - gemfile: '8.0' 43 | ruby: '3.0' 44 | - gemfile: '8.0' 45 | ruby: '3.1' 46 | fail-fast: false 47 | runs-on: ubuntu-latest 48 | 49 | env: 50 | BUNDLE_GEMFILE: gemfiles/Gemfile.rails-${{ matrix.gemfile }}.rb 51 | CI: true 52 | COVERALLS: true 53 | DB: ${{ matrix.database }} 54 | MYSQL_PASSWORD: root 55 | PGHOST: localhost 56 | PGPASSWORD: runner 57 | PGUSER: runner 58 | RAILS_ENV: test 59 | 60 | name: ${{ matrix.ruby }} ${{ matrix.database }} rails-${{ matrix.gemfile }} 61 | steps: 62 | - uses: actions/checkout@v4 63 | 64 | - run: sudo apt-get update && sudo apt-get install libsqlite3-dev -y 65 | 66 | - name: "Set up MySQL using VM's server" 67 | if: ${{ env.DB == 'mysql' }} 68 | run: | 69 | sudo apt-get install libmysqlclient-dev -y 70 | sudo systemctl start mysql.service 71 | 72 | - name: "Set up PostgreSQL using VM's server" 73 | if: ${{ env.DB == 'postgresql' }} 74 | run: | 75 | sudo apt-get install libpq-dev -y 76 | sudo systemctl start postgresql.service 77 | sudo -u postgres psql -c "CREATE USER runner WITH SUPERUSER PASSWORD 'runner'" 78 | sudo -u postgres createdb runner 79 | 80 | - uses: ruby/setup-ruby@v1 81 | with: 82 | bundler-cache: true 83 | ruby-version: ${{ matrix.ruby }} 84 | 85 | - run: bundle exec rake db:{create,up} 86 | - run: bundle exec rake test 87 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | -e guide.rb 2 | --files=Changelog.md,Guide.md 3 | --tag guide 4 | --hide-tag guide 5 | --private 6 | --protected 7 | --exclude lib/friendly_id/migration 8 | --markup=markdown 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | * Fix: history not using `parse_friendly_id`. ([#1020](https://github.com/norman/friendly_id/pull/1020)) 9 | 10 | ## 5.5.1 (2023-11-13) 11 | 12 | * Fix YARD doc generation. ([#1006](https://github.com/norman/friendly_id/pull/1006)) 13 | 14 | ## 5.5.0 (2022-11-16) 15 | 16 | * SimpleI18n: Handle regional locales ([#965](https://github.com/norman/friendly_id/pull/965)) 17 | * Fix: "unknown column" exception ([#943](https://github.com/norman/friendly_id/pull/943)) 18 | * Add: `allow_nil: true` to the Finder ([#995](https://github.com/norman/friendly_id/pull/995) and [#997](https://github.com/norman/friendly_id/pull/997)) 19 | 20 | ## 5.4.2 (2021-01-07) 21 | 22 | * Fix: Set slug before save if needed ([#948](https://github.com/norman/friendly_id/pull/948)) 23 | * Revert "Make `first_by_friendly_id` case insensitive using `downcase`" ([#951](https://github.com/norman/friendly_id/pull/951)) 24 | 25 | ## 5.4.1 (2020-11-06) 26 | 27 | * Fix unexpected `:slug` error on valid, unpersisted model ([#952](https://github.com/norman/friendly_id/pull/952)) 28 | 29 | ## 5.4.0 (2020-08-14) 30 | 31 | * Fix Ruby 2.7 keyword params deprecation warning ([#939](https://github.com/norman/friendly_id/pull/939)) 32 | * Fix Slug Validation in `unset_slug_if_invalid` ([#938](https://github.com/norman/friendly_id/pull/938)) 33 | * Make `first_by_friendly_id` case insensitive using `downcase` ([#787](https://github.com/norman/friendly_id/pull/787)) 34 | * Use `destroy_all` rather than `delete_all` when creating historical slugs ([#924](https://github.com/norman/friendly_id/pull/924)) 35 | * Set required ruby version to `>= 2.1.0` ([#923](https://github.com/norman/friendly_id/pull/923)) 36 | * Avoid using deprecated `update_attributes` ([#922](https://github.com/norman/friendly_id/pull/922)) 37 | 38 | ## 5.3.0 (2019-09-25) 39 | 40 | * Record history when scope changes but slug does not ([#916](https://github.com/norman/friendly_id/pull/916)) 41 | * Add support for Rails 6 ([#897](https://github.com/norman/friendly_id/pull/897)) 42 | 43 | ## 5.2.5 (2018-12-30) 44 | 45 | * Pass all possible parameters to ActiveRecord::RecordNotFound.new when raising the exception ([#890](https://github.com/norman/friendly_id/pull/890)) 46 | * Use composite index for queries by sluggable ([#882](https://github.com/norman/friendly_id/pull/882)) 47 | * Scoped: generate new slug if scope changed ([#878](https://github.com/norman/friendly_id/pull/878)) 48 | * Fix History + SequentiallySlugged issues ([#877](https://github.com/norman/friendly_id/pull/877)) 49 | * Support scoped with STI ([#745](https://github.com/norman/friendly_id/pull/745)) 50 | * Fix exists? to behave the same as find for numeric slugs ([#875](https://github.com/norman/friendly_id/pull/875)) 51 | * Remove dirty tracking code from to_param ([#867](https://github.com/norman/friendly_id/pull/867)) 52 | 53 | ## 5.2.4 (2018-04-24) 54 | 55 | * Fix compatibility with Rails versions 4.0 -> 5.2. ([#863](https://github.com/norman/friendly_id/pull/863)). 56 | * Refactor `History::FinderMethods` to use base implementation. ([#853](https://github.com/norman/friendly_id/pull/853)). 57 | * Defer loading of ActiveRecord to avoid config issues. ([#852](https://github.com/norman/friendly_id/pull/852)). 58 | * Ensure compatibility with paranoid deletion libraries. ([#838](https://github.com/norman/friendly_id/pull/838)). 59 | * Add treat_reserved_as_conflict option to initializer ([#847](https://github.com/norman/friendly_id/pull/847)). 60 | 61 | ## 5.2.3 (2017-09-22) 62 | 63 | * Added option to treat reserved words as conflicts ([#831](https://github.com/norman/friendly_id/pull/831)). 64 | 65 | ## 5.2.2 (2017-09-13) 66 | 67 | * Prevent warning on db:migrate in Rails 5.1 ([#826](https://github.com/norman/friendly_id/pull/826)). 68 | * Allow to set size limit for slug ([#809](https://github.com/norman/friendly_id/pull/809)). 69 | * Update specs and drop support for ruby 2.0.0 ([#824](https://github.com/norman/friendly_id/pull/824)). 70 | 71 | ## 5.2.1 (2017-04-09) 72 | 73 | * Change ActiveRecord::Base to ApplicationRecord ([#782](https://github.com/norman/friendly_id/pull/782)). 74 | * Refactor `Candidates#each` method. ([#773](https://github.com/norman/friendly_id/pull/773)). 75 | * Assign to configured slug column, not 'slug' when validation fails. ([#779](https://github.com/norman/friendly_id/pull/779)). 76 | * Fix sequential slugs when using History. ([#774](https://github.com/norman/friendly_id/pull/774)). 77 | 78 | ## 5.2.0 (2016-12-01) 79 | 80 | * Add sequential slug module for FriendlyId 4.x-style sequential slugs. ([#644](https://github.com/norman/friendly_id/pull/644)). 81 | * Make Candidates#each iterable without block ([#651](https://github.com/norman/friendly_id/pull/651)). 82 | * Ensure slug history prefers the record that most recently used the slug ([#663](https://github.com/norman/friendly_id/pull/663)). 83 | * Don't calculate all changes just to check if the param field has changed ([#667](https://github.com/norman/friendly_id/pull/667)). 84 | * 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)). 85 | * Fix order dependence bug between history and finders modules ([#718](https://github.com/norman/friendly_id/pull/718)) 86 | * Added ability to conditionally turn off `:dependent => :destroy` on FriendlyId::Slugs([#724](https://github.com/norman/friendly_id/pull/724)) 87 | * Add support for Rails 5. ([#728](https://github.com/norman/friendly_id/pull/728)) 88 | * 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)) 89 | 90 | ## 5.1.0 (2015-01-15) 91 | 92 | * FriendlyId will no longer allow blank strings as slugs ([#571](https://github.com/norman/friendly_id/pull/571)). 93 | * FriendlyId will now try to use the first non-reserved candidate as its 94 | slug and will only mark the record invalid if all candidates ([#536](https://github.com/norman/friendly_id/issues/536)). 95 | * Fix order dependence bug between history and scoped modules ([#588](https://github.com/norman/friendly_id/pull/588)). 96 | * Fix "friendly" finds on Rails 4.2 ([#607](https://github.com/norman/friendly_id/issues/607)). 97 | 98 | ## 5.0.4 (2014-05-29) 99 | 100 | * Bug fix for call to removed `primary` method on Edge Rails. ([#557](https://github.com/norman/friendly_id/pull/557)). 101 | * 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)). 102 | * Big fix to look for UUIDs only at the end of slugs ([#548](https://github.com/norman/friendly_id/pull/548)). 103 | * Various documentation and test setup improvements. 104 | 105 | ## 5.0.3 (2013-02-14) 106 | 107 | * Bug fix for calls to #dup with unslugged models ([#518](https://github.com/norman/friendly_id/pull/518)). 108 | * Bug fixes for STI ([#516](https://github.com/norman/friendly_id/pull/516)). 109 | * Bug fix for slug regeneration (both scoped and unscoped) ([#513](https://github.com/norman/friendly_id/pull/513)). 110 | * Bug fix for finds with models that use the :history module ([#509](https://github.com/norman/friendly_id/pull/509)). 111 | 112 | ## 5.0.2 (2013-12-10) 113 | 114 | * Query performance improvements ([#497](https://github.com/norman/friendly_id/pull/497)). 115 | * Documentation improvements (thanks [John Bachir](https://github.com/jjb)). 116 | * Minor refactoring of internals (thanks [Gagan Ahwad](https://github.com/gaganawhad)). 117 | * Set slug to `nil` on call to `dup` to ensure slug is generated ([#483](https://github.com/norman/friendly_id/pull/483)). 118 | 119 | ## 5.0.1 (2013-10-27) 120 | 121 | * Fix compatibility with Rails 4.0.1.rc3 (thanks [Herman verschooten](https://github.com/Hermanverschooten)). 122 | 123 | ## 5.0.0 (2013-10-16) 124 | 125 | * Fix to let scoped records reuse their slugs (thanks [Donny 126 | Kurnia](https://github.com/donnykurnia)). 127 | 128 | ## 5.0.0.rc.3 (2013-10-04) 129 | 130 | * Support friendly finds on associations in Rails 4.0.1 and up. They will 131 | currently work on Rails 4.0 associations only if `:inverse_of` is not used. 132 | In Rails 4-0-stable, associations have been modified to use a special 133 | relation class, giving FriendlyId a consistent extension point. Since the 134 | behavior in 4.0.0 is considered defective and fixed in 4-0-stable, FriendlyId 135 | 5.0 will not support friendly finds on inverse relelations in 4.0.0. For a 136 | reliable workaround, use the `friendly` scope for friendly finds on 137 | associations; this works on all Rails 4.0.x versions and will continue to be 138 | supported. 139 | * Documentation fixes. 140 | 141 | ## 5.0.0.rc2 (2013-09-29) 142 | 143 | * When the :finders addon has been included, use it in FriendlyId's internal 144 | finds to boost performance. 145 | * Use instance methods rather than class methods in migrations. 146 | * On find, fall back to super when the primary key is a character type. Thanks 147 | to [Jamie Davidson](https://github.com/jhdavids8). 148 | * Fix reversion to previously used slug from history table when 149 | `should_generate_new_friendly_id?` is overridden. 150 | * Fix sequencing of numeric slugs 151 | 152 | ## 5.0.0.rc1 (2013-08-28) 153 | 154 | * Removed some outdated tests. 155 | * Improved documentation. 156 | * Removed Guide from repository and added tasks to maintain docs up to date 157 | on Github pages at http://norman.github.io/friendly_id. 158 | 159 | ## 5.0.0.beta4 (2013-08-21) 160 | 161 | * Add an initializer to the generator; move the default reserved words there. 162 | * Allow assignment from {FriendlyId::Configuration#base}. 163 | * Fix bug whereby records could not reuse their own slugs. 164 | 165 | ## 5.0.0.beta3 (2013-08-20) 166 | 167 | * Update gemspec to ensure FriendlyId 5.0 is only used with AR 4.0.x. 168 | 169 | ## 5.0.0.beta2 (2013-08-16) 170 | 171 | * Add "finders" module to easily restore FriendlyId 4.0 finder behavior. 172 | 173 | ## 5.0.0.beta1 (2013-08-10) 174 | 175 | * Support for Rails 4. 176 | * Made the :scoped and :history modules compatible with each other (Andre Duffeck). 177 | * Removed class-level finders in favor of `friendly` scope (Norman Clarke). 178 | * Implemented "candidates" support (Norman Clarke). 179 | * Slug "sequences" are now GUIDs rather than numbers (Norman Clarke). 180 | * `find` no longer falls back to super unless id is fully numeric string (Norman Clarke). 181 | * Default sequence separator is now '-' rather than '--'. 182 | * Support for Globalize has been removed until Globalize supports Rails 4. 183 | * Removed support for Ruby < 1.9.3 and Rails < 4.0. 184 | 185 | ## 4.0.10.1 (2013-08-20) 186 | 187 | * Update dependencies in gemspec to avoid using with Active Record 4. 188 | * Fixed links in docs. 189 | 190 | ## 4.0.10 (2013-08-10) 191 | 192 | * Fixed table prefixes/suffixes being ignored (Jesse Farless). 193 | * Fixed sequence generation for slugs containing numbers (Adam Carroll). 194 | 195 | ## 4.0.9 (2012-10-31) 196 | 197 | * Fixed support for Rails 3.2.9.rc1 198 | 199 | ## 4.0.8 (2012-08-01) 200 | 201 | * Name internal anonymous class to fix marshall dump/load error (Jess Brown, Philip Arndt and Norman Clarke). 202 | 203 | * Avoid using deprecated `update_attribute` (Philip Arndt). 204 | 205 | * Added set_friendly_id method to Globalize module (Norman Clarke). 206 | 207 | * autoload FriendlyId::Slug; previously this class was not accessible from 208 | migrations unless required explicitly, which could cause some queries to 209 | unexpectedly fail (Norman Clarke). 210 | 211 | * Fix Mocha load order (Mark Turner). 212 | 213 | * Minor doc updates (Rob Yurkowski). 214 | 215 | * Other miscellaneous refactorings and doc updates. 216 | 217 | ## 4.0.7 (2012-06-06) 218 | 219 | * to_param just calls super when no friendly id is present, to keep the model's 220 | default behavior. (Andrew White) 221 | 222 | * FriendlyId can now properly sequence slugs that end in numbers even when a 223 | single dash is used as the separator (Tomás Arribas). 224 | 225 | ## 4.0.6 (2012-05-21) 226 | 227 | * Fix nil return value from to_param when save fails because of validation errors (Tomás Arribas) 228 | * Fix incorrect usage of i18n API (Vinicius Ferriani) 229 | * Improve error handling in reserved module (Adrián Mugnolo and Github user "nolamesa") 230 | 231 | ## 4.0.5 (2012-04-28) 232 | 233 | * Favor `includes` over `joins` in globalize to avoid read-only results (Jakub Wojtysiak) 234 | * Fix globalize compatibility with results from dynamic finders (Chris Salzberg) 235 | 236 | 237 | ## 4.0.4 (2012-03-26) 238 | 239 | * Fix globalize plugin to avoid issues with asset precompilation (Philip Arndt) 240 | 241 | 242 | ## 4.0.3 (2012-03-14) 243 | 244 | * Fix escape for '%' and '_' on SQLite (Norman Clarke and Sergey Petrunin) 245 | * Allow FriendlyId to be extended or included (Norman Clarke) 246 | * Allow Configuration#use to accept a Module (Norman Clarke) 247 | * Fix bugs with History module + STI (Norman Clarke and Sergey Petrunin) 248 | 249 | ## 4.0.2 (2012-03-12) 250 | 251 | * Improved conflict handling and performance in History module (Erik Ogan and Thomas Shafer) 252 | * Fixed bug that impeded using underscores as a sequence separator (Erik Ogan and Thomas Shafer) 253 | * Minor documentation improvements (Norman Clarke) 254 | 255 | ## 4.0.1 (2012-02-29) 256 | 257 | * Added support for Globalize 3 (Enrico Pilotto and Philip Arndt) 258 | * Allow the scoped module to use multiple scopes (Ben Caldwell) 259 | * Fixes for conflicting slugs in history module (Erik Ogan, Thomas Shafer, Evan Arnold) 260 | * Fix for conflicting slugs when using STI (Danny van der Heiden, Diederick Lawson) 261 | * Maintainence improvements (Norman Clarke, Philip Arndt, Thomas Darde, Lee Hambley) 262 | 263 | ## 4.0.0 (2011-12-27) 264 | 265 | This is a complete rewrite of FriendlyId, and introduces a smaller, faster and 266 | less ambitious codebase. The primary change is the relegation of external slugs 267 | to an optional addon, and the adoption of what were formerly "cached slugs" 268 | as the primary way of handling slugging. 269 | 270 | ## Older releases 271 | 272 | Please see the 3.x branch. 273 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "standard" 6 | gem "rake" 7 | 8 | group :development, :test do 9 | platforms :ruby do 10 | gem "byebug" 11 | gem "pry" 12 | end 13 | 14 | platforms :jruby do 15 | gem "activerecord-jdbcsqlite3-adapter", ">= 1.3.0.beta2" 16 | gem "kramdown" 17 | end 18 | 19 | platforms :ruby, :rbx do 20 | gem "sqlite3" 21 | gem "redcarpet" 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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](https://inch-ci.org/github/norman/friendly_id.svg?branch=master)](https://inch-ci.org/github/norman/friendly_id) 4 | 5 | # FriendlyId 6 | 7 | **For the most complete, user-friendly documentation, see the [FriendlyId Guide](https://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 | https://example.com/states/washington 16 | 17 | instead of: 18 | 19 | https://example.com/states/4323454 20 | 21 | 22 | ## Getting Help 23 | 24 | Ask questions on [Stack Overflow](https://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.5.0' 43 | ``` 44 | 45 | Note: You MUST use 5.0.0 or greater for Rails 4.0+. 46 | 47 | And then execute: 48 | 49 | ```shell 50 | bundle install 51 | ``` 52 | 53 | Add a `slug` column to the desired table (e.g. `Users`) 54 | ```shell 55 | rails g migration AddSlugToUsers slug:uniq 56 | ``` 57 | 58 | Generate the friendly configuration file and a new migration 59 | 60 | ```shell 61 | rails generate friendly_id 62 | ``` 63 | 64 | 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)) 65 | 66 | Run the migration scripts 67 | 68 | ```shell 69 | rails db:migrate 70 | ``` 71 | 72 | Edit the `app/models/user.rb` file as the following: 73 | 74 | ```ruby 75 | class User < ApplicationRecord 76 | extend FriendlyId 77 | friendly_id :name, use: :slugged 78 | end 79 | ``` 80 | 81 | Edit the `app/controllers/users_controller.rb` file and replace `User.find` by `User.friendly.find` 82 | 83 | ```ruby 84 | class UserController < ApplicationController 85 | def show 86 | @user = User.friendly.find(params[:id]) 87 | end 88 | end 89 | ``` 90 | 91 | Now when you create a new user like the following: 92 | 93 | ```ruby 94 | User.create! name: "Joe Schmoe" 95 | ``` 96 | 97 | You can then access the user show page using the URL http://localhost:3000/users/joe-schmoe. 98 | 99 | 100 | If you're adding FriendlyId to an existing app and need to generate slugs for 101 | existing users, do this from the console, runner, or add a Rake task: 102 | 103 | ```ruby 104 | User.find_each(&:save) 105 | ``` 106 | 107 | ## Options 108 | 109 | ### `:allow_nil` 110 | 111 | You can pass `allow_nil: true` to the `friendly.find()` method if you want to 112 | avoid raising `ActiveRecord::RecordNotFound` and accept `nil`. 113 | 114 | #### Example 115 | 116 | ```ruby 117 | MyModel.friendly.find("bad-slug") # where bad-slug is not a valid slug 118 | MyModel.friendly.find(123) # where 123 is not a valid primary key ID 119 | MyModel.friendly.find(nil) # maybe you have a variable/param that's potentially nil 120 | #=> raise ActiveRecord::RecordNotFound 121 | 122 | MyModel.friendly.find("bad-slug", allow_nil: true) 123 | MyModel.friendly.find(123, allow_nil: true) 124 | MyModel.friendly.find(nil, allow_nil: true) 125 | #=> nil 126 | ``` 127 | 128 | ## Bugs 129 | 130 | Please report them on the [Github issue 131 | tracker](https://github.com/norman/friendly_id/issues) for this project. 132 | 133 | If you have a bug to report, please include the following information: 134 | 135 | * **Version information for FriendlyId, Rails and Ruby.** 136 | * Full stack trace and error message (if you have them). 137 | * Any snippets of relevant model, view or controller code that shows how you 138 | are using FriendlyId. 139 | 140 | If you are able to, it helps even more if you can fork FriendlyId on Github, 141 | and add a test that reproduces the error you are experiencing. 142 | 143 | For more inspiration on how to report bugs, please see [this 144 | article](https://www.chiark.greenend.org.uk/~sgtatham/bugs.html). 145 | 146 | ## Thanks and Credits 147 | 148 | FriendlyId was originally created by Norman Clarke and Adrian Mugnolo, with 149 | significant help early in its life by Emilio Tagua. It is now maintained by 150 | Norman Clarke and Philip Arndt. 151 | 152 | We're deeply grateful for the generous contributions over the years from [many 153 | volunteers](https://github.com/norman/friendly_id/contributors). 154 | 155 | ## License 156 | 157 | Copyright (c) 2008-2020 Norman Clarke and contributors, released under the MIT 158 | license. 159 | 160 | Permission is hereby granted, free of charge, to any person obtaining a copy of 161 | this software and associated documentation files (the "Software"), to deal in 162 | the Software without restriction, including without limitation the rights to 163 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 164 | of the Software, and to permit persons to whom the Software is furnished to do 165 | so, subject to the following conditions: 166 | 167 | The above copyright notice and this permission notice shall be included in all 168 | copies or substantial portions of the Software. 169 | 170 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 171 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 172 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 173 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 174 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 175 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 176 | SOFTWARE. 177 | -------------------------------------------------------------------------------- /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 | `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 | `gem build friendly_id.gemspec` 27 | end 28 | 29 | desc "Build YARD documentation" 30 | task :yard do 31 | puts `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 | desc "Run each test class in a separate process" 56 | task :isolated do 57 | dir = File.expand_path("../test", __FILE__) 58 | Dir["#{dir}/*_test.rb"].each do |test| 59 | puts "Running #{test}:" 60 | puts `ruby -Ilib -Itest #{test}` 61 | end 62 | end 63 | end 64 | 65 | namespace :db do 66 | desc "Create the database" 67 | task create: :load_path do 68 | require "helper" 69 | driver = FriendlyId::Test::Database.driver 70 | config = FriendlyId::Test::Database.config[driver] 71 | commands = { 72 | "mysql" => "mysql -h #{config["host"]} -P #{config["port"]} -u #{config["username"]} --password=#{config["password"]} -e 'create database #{config["database"]};' >/dev/null", 73 | "postgres" => "psql -c 'create database #{config["database"]};' -U #{config["username"]} >/dev/null" 74 | } 75 | `#{commands[driver] || true}` 76 | end 77 | 78 | desc "Drop the database" 79 | task drop: :load_path do 80 | require "helper" 81 | driver = FriendlyId::Test::Database.driver 82 | config = FriendlyId::Test::Database.config[driver] 83 | commands = { 84 | "mysql" => "mysql -h #{config["host"]} -P #{config["port"]} -u #{config["username"]} --password=#{config["password"]} -e 'drop database #{config["database"]};' >/dev/null", 85 | "postgres" => "psql -c 'drop database #{config["database"]};' -U #{config["username"]} >/dev/null" 86 | } 87 | `#{commands[driver] || true}` 88 | end 89 | 90 | desc "Set up the database schema" 91 | task up: :load_path do 92 | require "helper" 93 | FriendlyId::Test::Schema.up 94 | end 95 | 96 | desc "Drop and recreate the database schema" 97 | task reset: [:drop, :create] 98 | end 99 | 100 | task doc: :yard 101 | 102 | task :docs do 103 | sh %(git checkout gh-pages && rake doc && git checkout @{-1}) 104 | end 105 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 do 8 | yield 9 | 10 | raise ActiveRecord::Rollback 11 | end 12 | end 13 | 14 | class Array 15 | def rand 16 | self[Kernel.rand(length)] 17 | end 18 | end 19 | 20 | Book = Class.new ActiveRecord::Base 21 | 22 | class Journalist < ActiveRecord::Base 23 | extend FriendlyId 24 | friendly_id :name, use: :slugged 25 | end 26 | 27 | class Manual < ActiveRecord::Base 28 | extend FriendlyId 29 | friendly_id :name, use: :history 30 | end 31 | 32 | class Restaurant < ActiveRecord::Base 33 | extend FriendlyId 34 | friendly_id :name, use: :finders 35 | end 36 | 37 | BOOKS = [] 38 | JOURNALISTS = [] 39 | MANUALS = [] 40 | RESTAURANTS = [] 41 | 42 | 100.times do 43 | name = FFaker::Name.name 44 | BOOKS << (Book.create! name: name).id 45 | JOURNALISTS << (Journalist.create! name: name).friendly_id 46 | MANUALS << (Manual.create! name: name).friendly_id 47 | RESTAURANTS << (Restaurant.create! name: name).friendly_id 48 | end 49 | 50 | ActiveRecord::Base.connection.execute "UPDATE manuals SET slug = NULL" 51 | 52 | Benchmark.bmbm do |x| 53 | x.report "find (without FriendlyId)" do 54 | N.times { Book.find BOOKS.rand } 55 | end 56 | 57 | x.report "find (in-table slug)" do 58 | N.times { Journalist.friendly.find JOURNALISTS.rand } 59 | end 60 | 61 | x.report "find (in-table slug; using finders module)" do 62 | N.times { Restaurant.find RESTAURANTS.rand } 63 | end 64 | 65 | x.report "find (external slug)" do 66 | N.times { Manual.friendly.find MANUALS.rand } 67 | end 68 | 69 | x.report "insert (without FriendlyId)" do 70 | N.times { transaction { Book.create name: FFaker::Name.name } } 71 | end 72 | 73 | x.report "insert (in-table-slug)" do 74 | N.times { transaction { Journalist.create name: FFaker::Name.name } } 75 | end 76 | 77 | x.report "insert (in-table-slug; using finders module)" do 78 | N.times { transaction { Restaurant.create name: FFaker::Name.name } } 79 | end 80 | 81 | x.report "insert (external slug)" do 82 | N.times { transaction { Manual.create name: FFaker::Name.name } } 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /certs/parndt.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIEljCCAv6gAwIBAgIBATANBgkqhkiG9w0BAQsFADBRMREwDwYDVQQDDAhydWJ5 3 | Z2VtczERMA8GCgmSJomT8ixkARkWAXAxFTATBgoJkiaJk/IsZAEZFgVhcm5kdDES 4 | MBAGCgmSJomT8ixkARkWAmlvMB4XDTIyMTExNTIyMjQzMFoXDTIzMTExNTIyMjQz 5 | MFowUTERMA8GA1UEAwwIcnVieWdlbXMxETAPBgoJkiaJk/IsZAEZFgFwMRUwEwYK 6 | CZImiZPyLGQBGRYFYXJuZHQxEjAQBgoJkiaJk/IsZAEZFgJpbzCCAaIwDQYJKoZI 7 | hvcNAQEBBQADggGPADCCAYoCggGBAMPq2bIEO+BmmBeuidSySK7xlL/LWBHzyDxw 8 | EMgWsHqJMDZYCZI4WoWbSTSSLrp5zPXLWN0hB23u3dxFp4RVygTTZkc8k05mteab 9 | fdREGgdcP+mY8/ASQSvb1VW6IM51Srgjy1SK0S5Qf3HAiQafFvRsxRkY0SWyth24 10 | ne/7HG667vHQ1+t0VFl8twupJE9S8p2zgX3eZBl2yRNm/kE5reUsOLvmS58Iri/X 11 | 9tnz0SGkzrKkim9OIByq7XkFLL3oaIyfbBVgOWilM5pvxj/xNuRH7EIM6aE3q0UZ 12 | xo7o9u9Iz2zApDEjejByPjxWAhLuP3v3bJyinRFE1rO47lEM/s6KM/6YooxvgYIN 13 | miYYFRtTj9nmKEMv6+h1mZ1/ZwqStTTRh/T90T65dcgsoqRd0JNvpNRjFrYH5cuj 14 | QZWMl/FE6AADm0GXa34ZiTQx3Wx2ctqJLFak8+imPwes90nCpiYmgaZpwBI+shjU 15 | AddbPDNq+EoxPMWTh0Er3w76fywOWQIDAQABo3kwdzAJBgNVHRMEAjAAMAsGA1Ud 16 | DwQEAwIEsDAdBgNVHQ4EFgQUxRJaTQZmtkN8FKUWVHKc2riND18wHgYDVR0RBBcw 17 | FYETcnVieWdlbXNAcC5hcm5kdC5pbzAeBgNVHRIEFzAVgRNydWJ5Z2Vtc0BwLmFy 18 | bmR0LmlvMA0GCSqGSIb3DQEBCwUAA4IBgQBSRGMkZ2dvJ0LSjFz+rIt3G3AZMbKD 19 | tjaaQRuC9rOkrl3Rml6h9j7cHYiM0wkTjXneFNySc8jWmM/jKnxiiUfUK9r1XL4n 20 | 71tz39+MD2lIpLVVEQ69MIoUseppNUTCg0mNghSDYNwISMD/hoWwbJudBi56DbhE 21 | xkulLbw8qtcEE+iilIKibe+eJF4platKScsOA7d1AuilR1/S245UzeqwwyI52/xK 22 | dfoP928X9Tb/48+83lWUgAgCQOd6WdfCpgQ5H6R90lc8L7OfuDR/vgcmSOTsNVgG 23 | 1TC3b2FISS0p0qfZsiS7BXh+ARoBKLXsV1a7WR36X0dUpajvk+zzBGrFCdbW43Gx 24 | wmJzIksYnf9Ktg8Ux+FLcRBGw4qEIyWvqmS0obB1Hke68rTg0uNTFcKXsNw33XF5 25 | fw1cbj95g7OPe0feGK8+afXh/L38vx/hIIOGlUEZ+HaWL2Dki/7vRGvda8dfOpG5 26 | bJfaoyKbVsrK+gGKFJv860zsO8lg6BGLsUw= 27 | -----END CERTIFICATE----- 28 | -------------------------------------------------------------------------------- /friendly_id.gemspec: -------------------------------------------------------------------------------- 1 | require File.expand_path("../lib/friendly_id/version", __FILE__) 2 | 3 | Gem::Specification.new do |s| 4 | s.name = "friendly_id" 5 | s.version = FriendlyId::VERSION 6 | s.authors = ["Norman Clarke", "Philip Arndt"] 7 | s.email = ["norman@njclarke.com", "p@arndt.io"] 8 | s.homepage = "https://github.com/norman/friendly_id" 9 | s.summary = "A comprehensive slugging and pretty-URL plugin." 10 | s.files = `git ls-files`.split("\n") 11 | s.test_files = `git ls-files -- {test}/*`.split("\n") 12 | s.require_paths = ["lib"] 13 | s.license = "MIT" 14 | 15 | s.required_ruby_version = ">= 2.1.0" 16 | 17 | s.add_dependency "activerecord", ">= 4.0.0" 18 | 19 | s.add_development_dependency "coveralls" 20 | s.add_development_dependency "railties", ">= 4.0" 21 | s.add_development_dependency "minitest", "~> 5.3" 22 | s.add_development_dependency "mocha", "~> 2.1" 23 | s.add_development_dependency "yard" 24 | s.add_development_dependency "i18n" 25 | s.add_development_dependency "ffaker" 26 | s.add_development_dependency "simplecov" 27 | 28 | s.description = "FriendlyId is the \"Swiss Army bulldozer\" of slugging " \ 29 | "and permalink plugins for Active Record. It lets you create pretty URLs " \ 30 | "and work with human-friendly strings as if they were numeric ids." 31 | 32 | s.cert_chain = [File.expand_path("certs/parndt.pem", __dir__)] 33 | if $PROGRAM_NAME.end_with?("gem") && ARGV.include?("build") && ARGV.include?(__FILE__) 34 | s.signing_key = File.expand_path("~/.ssh/gem-private_key.pem") 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /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 | end 23 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.rails-6.1.rb: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: "../" 4 | 5 | gem "activerecord", "~> 6.1.4" 6 | gem "railties", "~> 6.1.4" 7 | 8 | # Database Configuration 9 | group :development, :test do 10 | platforms :jruby do 11 | gem "activerecord-jdbcmysql-adapter", "~> 61.0" 12 | gem "activerecord-jdbcpostgresql-adapter", "~> 61.0" 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 | end 23 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.rails-7.0.rb: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: "../" 4 | 5 | gem "activerecord", "~> 7.0.0" 6 | gem "railties", "~> 7.0.0" 7 | 8 | # Database Configuration 9 | group :development, :test do 10 | platforms :jruby do 11 | gem "activerecord-jdbcmysql-adapter", "~> 61.0" 12 | gem "activerecord-jdbcpostgresql-adapter", "~> 61.0" 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 | end 23 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.rails-7.1.rb: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: "../" 4 | 5 | gem "activerecord", "~> 7.1.0" 6 | gem "railties", "~> 7.1.0" 7 | 8 | # Database Configuration 9 | group :development, :test do 10 | platforms :jruby do 11 | gem "activerecord-jdbcmysql-adapter", "~> 61.0" 12 | gem "activerecord-jdbcpostgresql-adapter", "~> 61.0" 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 | end 23 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.rails-7.2.rb: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: "../" 4 | 5 | gem "activerecord", "~> 7.2.0" 6 | gem "railties", "~> 7.2.0" 7 | 8 | # Database Configuration 9 | group :development, :test do 10 | platforms :jruby do 11 | gem "activerecord-jdbcmysql-adapter", "~> 61.0" 12 | gem "activerecord-jdbcpostgresql-adapter", "~> 61.0" 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 | end 23 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.rails-8.0.rb: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: "../" 4 | 5 | gem "activerecord", "~> 8.0.0" 6 | gem "railties", "~> 8.0.0" 7 | 8 | # Database Configuration 9 | group :development, :test do 10 | platforms :jruby do 11 | gem "activerecord-jdbcmysql-adapter", "~> 61.0" 12 | gem "activerecord-jdbcpostgresql-adapter", "~> 61.0" 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 | end 23 | -------------------------------------------------------------------------------- /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 | matches = File.read(path).match(/\n\s*# @guide begin\n(.*)\s*# @guide end/m) 8 | 9 | return if matches.nil? 10 | 11 | match = matches[1].to_s 12 | match.split("\n") 13 | .map { |x| x.sub(/^\s*#\s?/, "") } # Strip off the comment, leading whitespace, and the space after the comment 14 | .reject { |x| x =~ /^@/ } # Ignore yarddoc tags for the guide 15 | .join("\n").strip 16 | end 17 | 18 | File.open(File.expand_path("../Guide.md", __FILE__), "w:utf-8") do |guide| 19 | ["../friendly_id.rb", "base.rb", "finders.rb", "slugged.rb", "history.rb", 20 | "scoped.rb", "simple_i18n.rb", "reserved.rb"].each do |file| 21 | guide.write comments_from file 22 | guide.write "\n" 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/friendly_id.rb: -------------------------------------------------------------------------------- 1 | require "active_record" 2 | require "friendly_id/base" 3 | require "friendly_id/object_utils" 4 | require "friendly_id/configuration" 5 | require "friendly_id/finder_methods" 6 | 7 | # @guide begin 8 | # 9 | # ## About FriendlyId 10 | # 11 | # FriendlyId is an add-on to Ruby's Active Record that allows you to replace ids 12 | # in your URLs with strings: 13 | # 14 | # # without FriendlyId 15 | # http://example.com/states/4323454 16 | # 17 | # # with FriendlyId 18 | # http://example.com/states/washington 19 | # 20 | # It requires few changes to your application code and offers flexibility, 21 | # performance and a well-documented codebase. 22 | # 23 | # ### Core Concepts 24 | # 25 | # #### Slugs 26 | # 27 | # The concept of *slugs* is at the heart of FriendlyId. 28 | # 29 | # A slug is the part of a URL which identifies a page using human-readable 30 | # keywords, rather than an opaque identifier such as a numeric id. This can make 31 | # your application more friendly both for users and search engines. 32 | # 33 | # #### Finders: Slugs Act Like Numeric IDs 34 | # 35 | # To the extent possible, FriendlyId lets you treat text-based identifiers like 36 | # normal IDs. This means that you can perform finds with slugs just like you do 37 | # with numeric ids: 38 | # 39 | # Person.find(82542335) 40 | # Person.friendly.find("joe") 41 | # 42 | # @guide end 43 | module FriendlyId 44 | autoload :History, "friendly_id/history" 45 | autoload :Slug, "friendly_id/slug" 46 | autoload :SimpleI18n, "friendly_id/simple_i18n" 47 | autoload :Reserved, "friendly_id/reserved" 48 | autoload :Scoped, "friendly_id/scoped" 49 | autoload :Slugged, "friendly_id/slugged" 50 | autoload :Finders, "friendly_id/finders" 51 | autoload :SequentiallySlugged, "friendly_id/sequentially_slugged" 52 | 53 | # FriendlyId takes advantage of `extended` to do basic model setup, primarily 54 | # extending {FriendlyId::Base} to add {FriendlyId::Base#friendly_id 55 | # friendly_id} as a class method. 56 | # 57 | # Previous versions of FriendlyId simply patched ActiveRecord::Base, but this 58 | # version tries to be less invasive. 59 | # 60 | # In addition to adding {FriendlyId::Base#friendly_id friendly_id}, the class 61 | # instance variable +@friendly_id_config+ is added. This variable is an 62 | # instance of an anonymous subclass of {FriendlyId::Configuration}. This 63 | # allows subsequently loaded modules like {FriendlyId::Slugged} and 64 | # {FriendlyId::Scoped} to add functionality to the configuration class only 65 | # for the current class, rather than monkey patching 66 | # {FriendlyId::Configuration} directly. This isolates other models from large 67 | # feature changes an addon to FriendlyId could potentially introduce. 68 | # 69 | # The upshot of this is, you can have two Active Record models that both have 70 | # a @friendly_id_config, but each config object can have different methods 71 | # and behaviors depending on what modules have been loaded, without 72 | # conflicts. Keep this in mind if you're hacking on FriendlyId. 73 | # 74 | # For examples of this, see the source for {Scoped.included}. 75 | def self.extended(model_class) 76 | return if model_class.respond_to? :friendly_id 77 | class << model_class 78 | alias_method :relation_without_friendly_id, :relation 79 | end 80 | model_class.class_eval do 81 | extend Base 82 | @friendly_id_config = Class.new(Configuration).new(self) 83 | FriendlyId.defaults.call @friendly_id_config 84 | include Model 85 | end 86 | end 87 | 88 | # Allow developers to `include` FriendlyId or `extend` it. 89 | def self.included(model_class) 90 | model_class.extend self 91 | end 92 | 93 | # Set global defaults for all models using FriendlyId. 94 | # 95 | # The default defaults are to use the `:reserved` module and nothing else. 96 | # 97 | # @example 98 | # FriendlyId.defaults do |config| 99 | # config.base :name 100 | # config.use :slugged 101 | # end 102 | def self.defaults(&block) 103 | @defaults = block if block 104 | @defaults ||= ->(config) { config.use :reserved } 105 | end 106 | 107 | # Set the ActiveRecord table name prefix to friendly_id_ 108 | # 109 | # This makes 'slugs' into 'friendly_id_slugs' and also respects any 110 | # 'global' table_name_prefix set on ActiveRecord::Base. 111 | def self.table_name_prefix 112 | "#{ActiveRecord::Base.table_name_prefix}friendly_id_" 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/friendly_id/.gitattributes: -------------------------------------------------------------------------------- 1 | version.rb merge=ours 2 | -------------------------------------------------------------------------------- /lib/friendly_id/base.rb: -------------------------------------------------------------------------------- 1 | module FriendlyId 2 | # @guide 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 | # @guide end 58 | module Base 59 | # Configure FriendlyId's behavior in a model. 60 | # 61 | # class Post < ActiveRecord::Base 62 | # extend FriendlyId 63 | # friendly_id :title, :use => :slugged 64 | # end 65 | # 66 | # When given the optional block, this method will yield the class's instance 67 | # of {FriendlyId::Configuration} to the block before evaluating other 68 | # arguments, so configuration values set in the block may be overwritten by 69 | # the arguments. This order was chosen to allow passing the same proc to 70 | # multiple models, while being able to override the values it sets. Here is 71 | # a contrived example: 72 | # 73 | # $friendly_id_config_proc = Proc.new do |config| 74 | # config.base = :name 75 | # config.use :slugged 76 | # end 77 | # 78 | # class Foo < ActiveRecord::Base 79 | # extend FriendlyId 80 | # friendly_id &$friendly_id_config_proc 81 | # end 82 | # 83 | # class Bar < ActiveRecord::Base 84 | # extend FriendlyId 85 | # friendly_id :title, &$friendly_id_config_proc 86 | # end 87 | # 88 | # However, it's usually better to use {FriendlyId.defaults} for this: 89 | # 90 | # FriendlyId.defaults do |config| 91 | # config.base = :name 92 | # config.use :slugged 93 | # end 94 | # 95 | # class Foo < ActiveRecord::Base 96 | # extend FriendlyId 97 | # end 98 | # 99 | # class Bar < ActiveRecord::Base 100 | # extend FriendlyId 101 | # friendly_id :title 102 | # end 103 | # 104 | # In general you should use the block syntax either because of your personal 105 | # aesthetic preference, or because you need to share some functionality 106 | # between multiple models that can't be well encapsulated by 107 | # {FriendlyId.defaults}. 108 | # 109 | # ### Order Method Calls in a Block vs Ordering Options 110 | # 111 | # When calling this method without a block, you may set the hash options in 112 | # any order. 113 | # 114 | # However, when using block-style invocation, be sure to call 115 | # FriendlyId::Configuration's {FriendlyId::Configuration#use use} method 116 | # *prior* to the associated configuration options, because it will include 117 | # modules into your class, and these modules in turn may add required 118 | # configuration options to the `@friendly_id_configuraton`'s class: 119 | # 120 | # class Person < ActiveRecord::Base 121 | # friendly_id do |config| 122 | # # This will work 123 | # config.use :slugged 124 | # config.sequence_separator = ":" 125 | # end 126 | # end 127 | # 128 | # class Person < ActiveRecord::Base 129 | # friendly_id do |config| 130 | # # This will fail 131 | # config.sequence_separator = ":" 132 | # config.use :slugged 133 | # end 134 | # end 135 | # 136 | # ### Including Your Own Modules 137 | # 138 | # Because :use can accept a name or a Module, {FriendlyId.defaults defaults} 139 | # can be a convenient place to set up behavior common to all classes using 140 | # FriendlyId. You can include any module, or more conveniently, define one 141 | # on-the-fly. For example, let's say you want to make 142 | # [Babosa](http://github.com/norman/babosa) the default slugging library in 143 | # place of Active Support, and transliterate all slugs from Russian Cyrillic 144 | # to ASCII: 145 | # 146 | # require "babosa" 147 | # 148 | # FriendlyId.defaults do |config| 149 | # config.base = :name 150 | # config.use :slugged 151 | # config.use Module.new { 152 | # def normalize_friendly_id(text) 153 | # text.to_slug.normalize! :transliterations => [:russian, :latin] 154 | # end 155 | # } 156 | # end 157 | # 158 | # 159 | # @option options [Symbol,Module] :use The addon or name of an addon to use. 160 | # By default, FriendlyId provides {FriendlyId::Slugged :slugged}, 161 | # {FriendlyId::Reserved :finders}, {FriendlyId::History :history}, 162 | # {FriendlyId::Reserved :reserved}, {FriendlyId::Scoped :scoped}, and 163 | # {FriendlyId::SimpleI18n :simple_i18n}. 164 | # 165 | # @option options [Array] :reserved_words Available when using `:reserved`, 166 | # which is loaded by default. Sets an array of words banned for use as 167 | # the basis of a friendly_id. By default this includes "edit" and "new". 168 | # 169 | # @option options [Symbol] :scope Available when using `:scoped`. 170 | # Sets the relation or column used to scope generated friendly ids. This 171 | # option has no default value. 172 | # 173 | # @option options [Symbol] :sequence_separator Available when using `:slugged`. 174 | # Configures the sequence of characters used to separate a slug from a 175 | # sequence. Defaults to `-`. 176 | # 177 | # @option options [Symbol] :slug_column Available when using `:slugged`. 178 | # Configures the name of the column where FriendlyId will store the slug. 179 | # Defaults to `:slug`. 180 | # 181 | # @option options [Integer] :slug_limit Available when using `:slugged`. 182 | # Configures the limit of the slug. This option has no default value. 183 | # 184 | # @option options [Symbol] :slug_generator_class Available when using `:slugged`. 185 | # Sets the class used to generate unique slugs. You should not specify this 186 | # unless you're doing some extensive hacking on FriendlyId. Defaults to 187 | # {FriendlyId::SlugGenerator}. 188 | # 189 | # @yield Provides access to the model class's friendly_id_config, which 190 | # allows an alternate configuration syntax, and conditional configuration 191 | # logic. 192 | # 193 | # @option options [Symbol,Boolean] :dependent Available when using `:history`. 194 | # Sets the value used for the slugged association's dependent option. Use 195 | # `false` if you do not want to dependently destroy the associated slugged 196 | # record. Defaults to `:destroy`. 197 | # 198 | # @option options [Symbol] :routes When set to anything other than :friendly, 199 | # ensures that all routes generated by default do *not* use the slug. This 200 | # allows `form_for` and `polymorphic_path` to continue to generate paths like 201 | # `/team/1` instead of `/team/number-one`. You can still generate paths 202 | # like the latter using: team_path(team.slug). When set to :friendly, or 203 | # omitted, the default friendly_id behavior is maintained. 204 | # 205 | # @yieldparam config The model class's {FriendlyId::Configuration friendly_id_config}. 206 | def friendly_id(base = nil, options = {}, &block) 207 | yield friendly_id_config if block 208 | friendly_id_config.dependent = options.delete :dependent 209 | friendly_id_config.use options.delete :use 210 | friendly_id_config.send :set, base ? options.merge(base: base) : options 211 | include Model 212 | end 213 | 214 | # Returns a scope that includes the friendly finders. 215 | # @see FriendlyId::FinderMethods 216 | def friendly 217 | # Guess what? This causes Rails to invoke `extend` on the scope, which has 218 | # the well-known effect of blowing away Ruby's method cache. It would be 219 | # possible to make this more performant by subclassing the model's 220 | # relation class, extending that, and returning an instance of it in this 221 | # method. FriendlyId 4.0 did something similar. However in 5.0 I've 222 | # decided to only use Rails's public API in order to improve compatibility 223 | # and maintainability. If you'd like to improve the performance, your 224 | # efforts would be best directed at improving it at the root cause 225 | # of the problem - in Rails - because it would benefit more people. 226 | all.extending(friendly_id_config.finder_methods) 227 | end 228 | 229 | # Returns the model class's {FriendlyId::Configuration friendly_id_config}. 230 | # @note In the case of Single Table Inheritance (STI), this method will 231 | # duplicate the parent class's FriendlyId::Configuration and relation class 232 | # on first access. If you're concerned about thread safety, then be sure 233 | # to invoke {#friendly_id} in your class for each model. 234 | def friendly_id_config 235 | @friendly_id_config ||= base_class.friendly_id_config.dup.tap do |config| 236 | config.model_class = self 237 | end 238 | end 239 | 240 | def primary_key_type 241 | @primary_key_type ||= columns_hash[primary_key].type 242 | end 243 | end 244 | 245 | # Instance methods that will be added to all classes using FriendlyId. 246 | module Model 247 | def self.included(model_class) 248 | return if model_class.respond_to?(:friendly) 249 | end 250 | 251 | # Convenience method for accessing the class method of the same name. 252 | def friendly_id_config 253 | self.class.friendly_id_config 254 | end 255 | 256 | # Get the instance's friendly_id. 257 | def friendly_id 258 | send friendly_id_config.query_field 259 | end 260 | 261 | # Either the friendly_id, or the numeric id cast to a string. 262 | def to_param 263 | if friendly_id_config.routes == :friendly 264 | friendly_id.presence.to_param || super 265 | else 266 | super 267 | end 268 | end 269 | 270 | # Clears slug on duplicate records when calling `dup`. 271 | def dup 272 | super.tap { |duplicate| duplicate.slug = nil if duplicate.respond_to?("slug=") } 273 | end 274 | end 275 | end 276 | -------------------------------------------------------------------------------- /lib/friendly_id/candidates.rb: -------------------------------------------------------------------------------- 1 | require "securerandom" 2 | 3 | module FriendlyId 4 | # This class provides the slug candidate functionality. 5 | # @see FriendlyId::Slugged 6 | class Candidates 7 | include Enumerable 8 | 9 | def initialize(object, *array) 10 | @object = object 11 | @raw_candidates = to_candidate_array(object, array.flatten(1)) 12 | end 13 | 14 | def each(*args, &block) 15 | return candidates unless block 16 | candidates.each { |candidate| yield candidate } 17 | end 18 | 19 | private 20 | 21 | def candidates 22 | @candidates ||= begin 23 | candidates = normalize(@raw_candidates) 24 | filter(candidates) 25 | end 26 | end 27 | 28 | def normalize(candidates) 29 | candidates.map do |candidate| 30 | @object.normalize_friendly_id(candidate.map(&:call).join(" ")) 31 | end.select { |x| wanted?(x) } 32 | end 33 | 34 | def filter(candidates) 35 | unless candidates.all? { |x| reserved?(x) } 36 | candidates.reject! { |x| reserved?(x) } 37 | end 38 | candidates 39 | end 40 | 41 | def to_candidate_array(object, array) 42 | array.map do |candidate| 43 | case candidate 44 | when String 45 | [-> { candidate }] 46 | when Array 47 | to_candidate_array(object, candidate).flatten 48 | when Symbol 49 | [object.method(candidate)] 50 | else 51 | if candidate.respond_to?(:call) 52 | [candidate] 53 | else 54 | [-> { candidate.to_s }] 55 | end 56 | end 57 | end 58 | end 59 | 60 | def wanted?(slug) 61 | slug.present? 62 | end 63 | 64 | def reserved?(slug) 65 | config = @object.friendly_id_config 66 | return false unless config.uses? ::FriendlyId::Reserved 67 | return false unless config.reserved_words 68 | config.reserved_words.include?(slug) 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /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 | attr_writer :base 6 | 7 | # The default configuration options. 8 | attr_reader :defaults 9 | 10 | # The modules in use 11 | attr_reader :modules 12 | 13 | # The model class that this configuration belongs to. 14 | # @return ActiveRecord::Base 15 | attr_accessor :model_class 16 | 17 | # The module to use for finders 18 | attr_accessor :finder_methods 19 | 20 | # The value used for the slugged association's dependent option 21 | attr_accessor :dependent 22 | 23 | # Route generation preferences 24 | attr_accessor :routes 25 | 26 | def initialize(model_class, values = nil) 27 | @base = nil 28 | @model_class = model_class 29 | @defaults = {} 30 | @modules = [] 31 | @finder_methods = FriendlyId::FinderMethods 32 | self.routes = :friendly 33 | set values 34 | end 35 | 36 | # Lets you specify the addon modules to use with FriendlyId. 37 | # 38 | # This method is invoked by {FriendlyId::Base#friendly_id friendly_id} when 39 | # passing the `:use` option, or when using {FriendlyId::Base#friendly_id 40 | # friendly_id} with a block. 41 | # 42 | # @example 43 | # class Book < ActiveRecord::Base 44 | # extend FriendlyId 45 | # friendly_id :name, :use => :slugged 46 | # end 47 | # 48 | # @param [#to_s,Module] modules Arguments should be Modules, or symbols or 49 | # strings that correspond with the name of an addon to use with FriendlyId. 50 | # By default FriendlyId provides `:slugged`, `:finders`, `:history`, 51 | # `:reserved`, `:simple_i18n`, and `:scoped`. 52 | def use(*modules) 53 | modules.to_a.flatten.compact.map do |object| 54 | mod = get_module(object) 55 | mod.setup(@model_class) if mod.respond_to?(:setup) 56 | @model_class.send(:include, mod) unless uses? object 57 | end 58 | end 59 | 60 | # Returns whether the given module is in use. 61 | def uses?(mod) 62 | @model_class < get_module(mod) 63 | end 64 | 65 | # The column that FriendlyId will use to find the record when querying by 66 | # friendly id. 67 | # 68 | # This method is generally only used internally by FriendlyId. 69 | # @return String 70 | def query_field 71 | base.to_s 72 | end 73 | 74 | # The base column or method used by FriendlyId as the basis of a friendly id 75 | # or slug. 76 | # 77 | # For models that don't use {FriendlyId::Slugged}, this is the column that 78 | # is used to store the friendly id. For models using {FriendlyId::Slugged}, 79 | # the base is a column or method whose value is used as the basis of the 80 | # slug. 81 | # 82 | # For example, if you have a model representing blog posts and that uses 83 | # slugs, you likely will want to use the "title" attribute as the base, and 84 | # FriendlyId will take care of transforming the human-readable title into 85 | # something suitable for use in a URL. 86 | # 87 | # If you pass an argument, it will be used as the base. Otherwise the current 88 | # value is returned. 89 | # 90 | # @param value A symbol referencing a column or method in the model. This 91 | # value is usually set by passing it as the first argument to 92 | # {FriendlyId::Base#friendly_id friendly_id}. 93 | def base(*value) 94 | if value.empty? 95 | @base 96 | else 97 | self.base = value.first 98 | end 99 | end 100 | 101 | private 102 | 103 | def get_module(object) 104 | Module === object ? object : FriendlyId.const_get(object.to_s.titleize.camelize.gsub(/\s+/, "")) 105 | end 106 | 107 | def set(values) 108 | values&.each { |name, value| send "#{name}=", value } 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/friendly_id/finder_methods.rb: -------------------------------------------------------------------------------- 1 | module FriendlyId 2 | module FinderMethods 3 | # Finds a record using the given id. 4 | # 5 | # If the id is "unfriendly", it will call the original find method. 6 | # If the id is a numeric string like '123' it will first look for a friendly 7 | # id matching '123' and then fall back to looking for a record with the 8 | # numeric id '123'. 9 | # 10 | # @param [Boolean] allow_nil (default: false) 11 | # Use allow_nil: true if you'd like the finder to return nil instead of 12 | # raising ActivRecord::RecordNotFound 13 | # 14 | # ### Example 15 | # 16 | # MyModel.friendly.find("bad-slug") 17 | # #=> raise ActiveRecord::RecordNotFound 18 | # 19 | # MyModel.friendly.find("bad-slug", allow_nil: true) 20 | # #=> nil 21 | # 22 | # Since FriendlyId 5.0, if the id is a nonnumeric string like '123-foo' it 23 | # will *only* search by friendly id and not fall back to the regular find 24 | # method. 25 | # 26 | # If you want to search only by the friendly id, use {#find_by_friendly_id}. 27 | # @raise ActiveRecord::RecordNotFound 28 | def find(*args, allow_nil: false) 29 | id = args.first 30 | return super(*args) if args.count != 1 || id.unfriendly_id? 31 | first_by_friendly_id(id).tap { |result| return result unless result.nil? } 32 | return super(*args) if potential_primary_key?(id) 33 | 34 | raise_not_found_exception(id) unless allow_nil 35 | rescue ActiveRecord::RecordNotFound => exception 36 | raise exception unless allow_nil 37 | end 38 | 39 | # Returns true if a record with the given id exists. 40 | def exists?(conditions = :none) 41 | return super if conditions.unfriendly_id? 42 | return true if exists_by_friendly_id?(conditions) 43 | super 44 | end 45 | 46 | # Finds exclusively by the friendly id, completely bypassing original 47 | # `find`. 48 | # @raise ActiveRecord::RecordNotFound 49 | def find_by_friendly_id(id) 50 | first_by_friendly_id(id) or raise_not_found_exception(id) 51 | end 52 | 53 | def exists_by_friendly_id?(id) 54 | where(friendly_id_config.query_field => parse_friendly_id(id)).exists? 55 | end 56 | 57 | private 58 | 59 | def potential_primary_key?(id) 60 | key_type = primary_key_type 61 | # Hook for "ActiveModel::Type::Integer" instance. 62 | key_type = key_type.type if key_type.respond_to?(:type) 63 | case key_type 64 | when :integer 65 | begin 66 | Integer(id, 10) 67 | rescue 68 | false 69 | end 70 | when :uuid 71 | id.match(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/) 72 | else 73 | true 74 | end 75 | end 76 | 77 | def first_by_friendly_id(id) 78 | find_by(friendly_id_config.query_field => parse_friendly_id(id)) 79 | end 80 | 81 | # Parse the given value to make it suitable for use as a slug according to 82 | # your application's rules. 83 | # 84 | # This method is not intended to be invoked directly; FriendlyId uses it 85 | # internally to process a slug into string to use as a finder. 86 | # 87 | # However, if FriendlyId's default slug parsing doesn't suit your needs, 88 | # you can override this method in your model class to control exactly how 89 | # slugs are generated. 90 | # 91 | # ### Example 92 | # 93 | # class Person < ActiveRecord::Base 94 | # extend FriendlyId 95 | # friendly_id :name_and_location 96 | # 97 | # def name_and_location 98 | # "#{name} from #{location}" 99 | # end 100 | # 101 | # # Use default slug, but lower case 102 | # # If `id` is "Jane-Doe" or "JANE-DOE", this finds data by "jane-doe" 103 | # def parse_friendly_id(slug) 104 | # super.downcase 105 | # end 106 | # end 107 | # 108 | # @param [#to_s] value The slug to be parsed. 109 | # @return The parsed slug, which is not modified by default. 110 | def parse_friendly_id(value) 111 | value 112 | end 113 | 114 | def raise_not_found_exception(id) 115 | message = "can't find record with friendly id: #{id.inspect}" 116 | if ActiveRecord.version < Gem::Version.create("5.0") 117 | raise ActiveRecord::RecordNotFound.new(message) 118 | else 119 | raise ActiveRecord::RecordNotFound.new(message, name, friendly_id_config.query_field, id) 120 | end 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /lib/friendly_id/finders.rb: -------------------------------------------------------------------------------- 1 | module FriendlyId 2 | # @guide begin 3 | # 4 | # ## Performing Finds with FriendlyId 5 | # 6 | # FriendlyId offers enhanced finders which will search for your record by 7 | # friendly id, and fall back to the numeric id if necessary. This makes it easy 8 | # to add FriendlyId to an existing application with minimal code modification. 9 | # 10 | # By default, these methods are available only on the `friendly` scope: 11 | # 12 | # Restaurant.friendly.find('plaza-diner') #=> works 13 | # Restaurant.friendly.find(23) #=> also works 14 | # Restaurant.find(23) #=> still works 15 | # Restaurant.find('plaza-diner') #=> will not work 16 | # 17 | # ### Restoring FriendlyId 4.0-style finders 18 | # 19 | # Prior to version 5.0, FriendlyId overrode the default finder methods to perform 20 | # friendly finds all the time. This required modifying parts of Rails that did 21 | # not have a public API, which was harder to maintain and at times caused 22 | # compatiblity problems. In 5.0 we decided to change the library's defaults and add 23 | # the friendly finder methods only to the `friendly` scope in order to boost 24 | # compatiblity. However, you can still opt-in to original functionality very 25 | # easily by using the `:finders` addon: 26 | # 27 | # class Restaurant < ActiveRecord::Base 28 | # extend FriendlyId 29 | # 30 | # scope :active, -> {where(:active => true)} 31 | # 32 | # friendly_id :name, :use => [:slugged, :finders] 33 | # end 34 | # 35 | # Restaurant.friendly.find('plaza-diner') #=> works 36 | # Restaurant.find('plaza-diner') #=> now also works 37 | # Restaurant.active.find('plaza-diner') #=> now also works 38 | # 39 | # ### Updating your application to use FriendlyId's finders 40 | # 41 | # Unless you've chosen to use the `:finders` addon, be sure to modify the finders 42 | # in your controllers to use the `friendly` scope. For example: 43 | # 44 | # # before 45 | # def set_restaurant 46 | # @restaurant = Restaurant.find(params[:id]) 47 | # end 48 | # 49 | # # after 50 | # def set_restaurant 51 | # @restaurant = Restaurant.friendly.find(params[:id]) 52 | # end 53 | # 54 | # #### Active Admin 55 | # 56 | # Unless you use the `:finders` addon, you should modify your admin controllers 57 | # for models that use FriendlyId with something similar to the following: 58 | # 59 | # controller do 60 | # def find_resource 61 | # scoped_collection.friendly.find(params[:id]) 62 | # end 63 | # end 64 | # 65 | # @guide end 66 | module Finders 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("::", "_")}_#{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 | -------------------------------------------------------------------------------- /lib/friendly_id/history.rb: -------------------------------------------------------------------------------- 1 | module FriendlyId 2 | # @guide begin 3 | # 4 | # ## History: Avoiding 404's When Slugs Change 5 | # 6 | # FriendlyId's {FriendlyId::History History} module adds the ability to store a 7 | # log of a model's slugs, so that when its friendly id changes, it's still 8 | # possible to perform finds by the old id. 9 | # 10 | # The primary use case for this is avoiding broken URLs. 11 | # 12 | # ### Setup 13 | # 14 | # In order to use this module, you must add a table to your database schema to 15 | # store the slug records. FriendlyId provides a generator for this purpose: 16 | # 17 | # rails generate friendly_id 18 | # rake db:migrate 19 | # 20 | # This will add a table named `friendly_id_slugs`, used by the {FriendlyId::Slug} 21 | # model. 22 | # 23 | # ### Considerations 24 | # 25 | # Because recording slug history requires creating additional database records, 26 | # this module has an impact on the performance of the associated model's `create` 27 | # method. 28 | # 29 | # ### Example 30 | # 31 | # class Post < ActiveRecord::Base 32 | # extend FriendlyId 33 | # friendly_id :title, :use => :history 34 | # end 35 | # 36 | # class PostsController < ApplicationController 37 | # 38 | # before_filter :find_post 39 | # 40 | # ... 41 | # 42 | # def find_post 43 | # @post = Post.friendly.find params[:id] 44 | # 45 | # # If an old id or a numeric id was used to find the record, then 46 | # # the request slug will not match the current slug, and we should do 47 | # # a 301 redirect to the new path 48 | # if params[:id] != @post.slug 49 | # return redirect_to @post, :status => :moved_permanently 50 | # end 51 | # end 52 | # end 53 | # 54 | # @guide end 55 | module History 56 | module Configuration 57 | def dependent_value 58 | dependent.nil? ? :destroy : dependent 59 | end 60 | end 61 | 62 | def self.setup(model_class) 63 | model_class.instance_eval do 64 | friendly_id_config.use :slugged 65 | friendly_id_config.class.send :include, History::Configuration 66 | friendly_id_config.finder_methods = FriendlyId::History::FinderMethods 67 | FriendlyId::Finders.setup(model_class) if friendly_id_config.uses? :finders 68 | end 69 | end 70 | 71 | # Configures the model instance to use the History add-on. 72 | def self.included(model_class) 73 | model_class.class_eval do 74 | has_many :slugs, -> { order(id: :desc) }, **{ 75 | as: :sluggable, 76 | dependent: @friendly_id_config.dependent_value, 77 | class_name: Slug.to_s 78 | } 79 | 80 | after_save :create_slug 81 | end 82 | end 83 | 84 | module FinderMethods 85 | include ::FriendlyId::FinderMethods 86 | 87 | def exists_by_friendly_id?(id) 88 | super || joins(:slugs).where(slug_history_clause(parse_friendly_id(id))).exists? 89 | end 90 | 91 | private 92 | 93 | def first_by_friendly_id(id) 94 | super || slug_table_record(parse_friendly_id(id)) 95 | end 96 | 97 | def slug_table_record(id) 98 | select(quoted_table_name + ".*").joins(:slugs).where(slug_history_clause(id)).order(Slug.arel_table[:id].desc).first 99 | end 100 | 101 | def slug_history_clause(id) 102 | Slug.arel_table[:sluggable_type].eq(base_class.to_s).and(Slug.arel_table[:slug].eq(id)) 103 | end 104 | end 105 | 106 | private 107 | 108 | # If we're updating, don't consider historic slugs for the same record 109 | # to be conflicts. This will allow a record to revert to a previously 110 | # used slug. 111 | def scope_for_slug_generator 112 | relation = super.joins(:slugs) 113 | unless new_record? 114 | relation = relation.merge(Slug.where("sluggable_id <> ?", id)) 115 | end 116 | if friendly_id_config.uses?(:scoped) 117 | relation = relation.where(Slug.arel_table[:scope].eq(serialized_scope)) 118 | end 119 | relation 120 | end 121 | 122 | def create_slug 123 | return unless friendly_id 124 | return if history_is_up_to_date? 125 | # Allow reversion back to a previously used slug 126 | relation = slugs.where(slug: friendly_id) 127 | if friendly_id_config.uses?(:scoped) 128 | relation = relation.where(scope: serialized_scope) 129 | end 130 | relation.destroy_all unless relation.empty? 131 | slugs.create! do |record| 132 | record.slug = friendly_id 133 | record.scope = serialized_scope if friendly_id_config.uses?(:scoped) 134 | end 135 | end 136 | 137 | def history_is_up_to_date? 138 | latest_history = slugs.first 139 | check = latest_history.try(:slug) == friendly_id 140 | if friendly_id_config.uses?(:scoped) 141 | check &&= latest_history.scope == serialized_scope 142 | end 143 | check 144 | end 145 | end 146 | end 147 | -------------------------------------------------------------------------------- /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 explicitly 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/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 | -------------------------------------------------------------------------------- /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 | # True if the id is definitely friendly, false if definitely unfriendly, 23 | # else nil. 24 | # 25 | # An object is considired "definitely unfriendly" if its class is or 26 | # inherits from ActiveRecord::Base, Array, Hash, NilClass, Numeric, or 27 | # Symbol. 28 | # 29 | # An object is considered "definitely friendly" if it responds to +to_i+, 30 | # and its value when cast to an integer and then back to a string is 31 | # different from its value when merely cast to a string: 32 | # 33 | # 123.friendly_id? #=> false 34 | # :id.friendly_id? #=> false 35 | # {:name => 'joe'}.friendly_id? #=> false 36 | # ['name = ?', 'joe'].friendly_id? #=> false 37 | # nil.friendly_id? #=> false 38 | # "123".friendly_id? #=> nil 39 | # "abc123".friendly_id? #=> true 40 | def friendly_id? 41 | true if respond_to?(:to_i) && to_i.to_s != to_s 42 | end 43 | 44 | # True if the id is definitely unfriendly, false if definitely friendly, 45 | # else nil. 46 | def unfriendly_id? 47 | val = friendly_id? 48 | !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 | -------------------------------------------------------------------------------- /lib/friendly_id/reserved.rb: -------------------------------------------------------------------------------- 1 | module FriendlyId 2 | # @guide begin 3 | # 4 | # ## Reserved Words 5 | # 6 | # The {FriendlyId::Reserved Reserved} module adds the ability to exclude a list of 7 | # words from use as FriendlyId slugs. 8 | # 9 | # With Ruby on Rails, FriendlyId's generator generates an initializer that 10 | # reserves some words such as "new" and "edit" using {FriendlyId.defaults 11 | # FriendlyId.defaults}. 12 | # 13 | # Note that the error messages for fields will appear on the field 14 | # `:friendly_id`. If you are using Rails's scaffolded form errors display, then 15 | # it will have no field to highlight. If you'd like to change this so that 16 | # scaffolding works as expected, one way to accomplish this is to move the error 17 | # message to a different field. For example: 18 | # 19 | # class Person < ActiveRecord::Base 20 | # extend FriendlyId 21 | # friendly_id :name, use: :slugged 22 | # 23 | # after_validation :move_friendly_id_error_to_name 24 | # 25 | # def move_friendly_id_error_to_name 26 | # errors.add :name, *errors.delete(:friendly_id) if errors[:friendly_id].present? 27 | # end 28 | # end 29 | # 30 | # @guide end 31 | module Reserved 32 | # When included, this module adds configuration options to the model class's 33 | # friendly_id_config. 34 | def self.included(model_class) 35 | model_class.class_eval do 36 | friendly_id_config.class.send :include, Reserved::Configuration 37 | validates_exclusion_of :friendly_id, in: ->(_) { 38 | friendly_id_config.reserved_words || [] 39 | } 40 | end 41 | end 42 | 43 | # This module adds the `:reserved_words` configuration option to 44 | # {FriendlyId::Configuration FriendlyId::Configuration}. 45 | module Configuration 46 | attr_accessor :reserved_words 47 | attr_accessor :treat_reserved_as_conflict 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/friendly_id/scoped.rb: -------------------------------------------------------------------------------- 1 | require "friendly_id/slugged" 2 | 3 | module FriendlyId 4 | # @guide begin 5 | # 6 | # ## Unique Slugs by Scope 7 | # 8 | # The {FriendlyId::Scoped} module allows FriendlyId to generate unique slugs 9 | # within a scope. 10 | # 11 | # This allows, for example, two restaurants in different cities to have the slug 12 | # `joes-diner`: 13 | # 14 | # class Restaurant < ActiveRecord::Base 15 | # extend FriendlyId 16 | # belongs_to :city 17 | # friendly_id :name, :use => :scoped, :scope => :city 18 | # end 19 | # 20 | # class City < ActiveRecord::Base 21 | # extend FriendlyId 22 | # has_many :restaurants 23 | # friendly_id :name, :use => :slugged 24 | # end 25 | # 26 | # City.friendly.find("seattle").restaurants.friendly.find("joes-diner") 27 | # City.friendly.find("chicago").restaurants.friendly.find("joes-diner") 28 | # 29 | # Without :scoped in this case, one of the restaurants would have the slug 30 | # `joes-diner` and the other would have `joes-diner-f9f3789a-daec-4156-af1d-fab81aa16ee5`. 31 | # 32 | # The value for the `:scope` option can be the name of a `belongs_to` relation, or 33 | # a column. 34 | # 35 | # Additionally, the `:scope` option can receive an array of scope values: 36 | # 37 | # class Cuisine < ActiveRecord::Base 38 | # extend FriendlyId 39 | # has_many :restaurants 40 | # friendly_id :name, :use => :slugged 41 | # end 42 | # 43 | # class City < ActiveRecord::Base 44 | # extend FriendlyId 45 | # has_many :restaurants 46 | # friendly_id :name, :use => :slugged 47 | # end 48 | # 49 | # class Restaurant < ActiveRecord::Base 50 | # extend FriendlyId 51 | # belongs_to :city 52 | # friendly_id :name, :use => :scoped, :scope => [:city, :cuisine] 53 | # end 54 | # 55 | # All supplied values will be used to determine scope. 56 | # 57 | # ### Finding Records by Friendly ID 58 | # 59 | # If you are using scopes your friendly ids may not be unique, so a simple find 60 | # like: 61 | # 62 | # Restaurant.friendly.find("joes-diner") 63 | # 64 | # may return the wrong record. In these cases it's best to query through the 65 | # relation: 66 | # 67 | # @city.restaurants.friendly.find("joes-diner") 68 | # 69 | # Alternatively, you could pass the scope value as a query parameter: 70 | # 71 | # Restaurant.where(:city_id => @city.id).friendly.find("joes-diner") 72 | # 73 | # 74 | # ### Finding All Records That Match a Scoped ID 75 | # 76 | # Query the slug column directly: 77 | # 78 | # Restaurant.where(:slug => "joes-diner") 79 | # 80 | # ### Routes for Scoped Models 81 | # 82 | # Recall that FriendlyId is a database-centric library, and does not set up any 83 | # routes for scoped models. You must do this yourself in your application. Here's 84 | # an example of one way to set this up: 85 | # 86 | # # in routes.rb 87 | # resources :cities do 88 | # resources :restaurants 89 | # end 90 | # 91 | # # in views 92 | # <%= link_to 'Show', [@city, @restaurant] %> 93 | # 94 | # # in controllers 95 | # @city = City.friendly.find(params[:city_id]) 96 | # @restaurant = @city.restaurants.friendly.find(params[:id]) 97 | # 98 | # # URLs: 99 | # http://example.org/cities/seattle/restaurants/joes-diner 100 | # http://example.org/cities/chicago/restaurants/joes-diner 101 | # 102 | # @guide end 103 | module Scoped 104 | # FriendlyId::Config.use will invoke this method when present, to allow 105 | # loading dependent modules prior to overriding them when necessary. 106 | def self.setup(model_class) 107 | model_class.friendly_id_config.use :slugged 108 | end 109 | 110 | # Sets up behavior and configuration options for FriendlyId's scoped slugs 111 | # feature. 112 | def self.included(model_class) 113 | model_class.class_eval do 114 | friendly_id_config.class.send :include, Configuration 115 | end 116 | end 117 | 118 | def serialized_scope 119 | friendly_id_config.scope_columns.sort.map { |column| "#{column}:#{send(column)}" }.join(",") 120 | end 121 | 122 | def scope_for_slug_generator 123 | if friendly_id_config.uses?(:History) 124 | return super 125 | end 126 | relation = self.class.base_class.unscoped.friendly 127 | friendly_id_config.scope_columns.each do |column| 128 | relation = relation.where(column => send(column)) 129 | end 130 | primary_key_name = self.class.primary_key 131 | relation.where(self.class.arel_table[primary_key_name].not_eq(send(primary_key_name))) 132 | end 133 | private :scope_for_slug_generator 134 | 135 | def slug_generator 136 | friendly_id_config.slug_generator_class.new(scope_for_slug_generator, friendly_id_config) 137 | end 138 | private :slug_generator 139 | 140 | def should_generate_new_friendly_id? 141 | (changed & friendly_id_config.scope_columns).any? || super 142 | end 143 | 144 | # This module adds the `:scope` configuration option to 145 | # {FriendlyId::Configuration FriendlyId::Configuration}. 146 | module Configuration 147 | # Gets the scope value. 148 | # 149 | # When setting this value, the argument should be a symbol referencing a 150 | # `belongs_to` relation, or a column. 151 | # 152 | # @return Symbol The scope value 153 | attr_accessor :scope 154 | 155 | # Gets the scope columns. 156 | # 157 | # Checks to see if the `:scope` option passed to 158 | # {FriendlyId::Base#friendly_id} refers to a relation, and if so, returns 159 | # the realtion's foreign key. Otherwise it assumes the option value was 160 | # the name of column and returns it cast to a String. 161 | # 162 | # @return String The scope column 163 | def scope_columns 164 | [@scope].flatten.map { |s| (reflection_foreign_key(s) or s).to_s } 165 | end 166 | 167 | private 168 | 169 | def reflection_foreign_key(scope) 170 | reflection = model_class.reflections[scope] || model_class.reflections[scope.to_s] 171 | reflection.try(:foreign_key) 172 | end 173 | end 174 | end 175 | end 176 | -------------------------------------------------------------------------------- /lib/friendly_id/sequentially_slugged.rb: -------------------------------------------------------------------------------- 1 | require_relative "sequentially_slugged/calculator" 2 | 3 | module FriendlyId 4 | module SequentiallySlugged 5 | def self.setup(model_class) 6 | model_class.friendly_id_config.use :slugged 7 | end 8 | 9 | def resolve_friendly_id_conflict(candidate_slugs) 10 | candidate = candidate_slugs.first 11 | return if candidate.nil? 12 | 13 | Calculator.new( 14 | scope_for_slug_generator, 15 | candidate, 16 | slug_column, 17 | friendly_id_config.sequence_separator, 18 | slug_base_class 19 | ).next_slug 20 | end 21 | 22 | private 23 | 24 | def slug_base_class 25 | if friendly_id_config.uses?(:history) 26 | Slug 27 | else 28 | self.class.base_class 29 | end 30 | end 31 | 32 | def slug_column 33 | if friendly_id_config.uses?(:history) 34 | :slug 35 | else 36 | friendly_id_config.slug_column 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/friendly_id/sequentially_slugged/calculator.rb: -------------------------------------------------------------------------------- 1 | module FriendlyId 2 | module SequentiallySlugged 3 | class Calculator 4 | attr_accessor :scope, :slug, :slug_column, :sequence_separator 5 | 6 | def initialize(scope, slug, slug_column, sequence_separator, base_class) 7 | @scope = scope 8 | @slug = slug 9 | table_name = scope.connection.quote_table_name(base_class.arel_table.name) 10 | @slug_column = "#{table_name}.#{scope.connection.quote_column_name(slug_column)}" 11 | @sequence_separator = sequence_separator 12 | end 13 | 14 | def next_slug 15 | slug + sequence_separator + next_sequence_number.to_s 16 | end 17 | 18 | private 19 | 20 | def conflict_query 21 | base = "#{slug_column} = ? OR #{slug_column} LIKE ?" 22 | # Awful hack for SQLite3, which does not pick up '\' as the escape character 23 | # without this. 24 | base << " ESCAPE '\\'" if /sqlite/i.match?(scope.connection.adapter_name) 25 | base 26 | end 27 | 28 | def next_sequence_number 29 | last_sequence_number ? last_sequence_number + 1 : 2 30 | end 31 | 32 | def last_sequence_number 33 | # Reject slug_conflicts that doesn't come from the first_candidate 34 | # Map all sequence numbers and take the maximum 35 | slug_conflicts 36 | .reject { |slug_conflict| !regexp.match(slug_conflict) } 37 | .map { |slug_conflict| regexp.match(slug_conflict)[1].to_i } 38 | .max 39 | end 40 | 41 | # Return the unnumbered (shortest) slug first, followed by the numbered ones 42 | # in ascending order. 43 | def ordering_query 44 | "#{sql_length}(#{slug_column}) ASC, #{slug_column} ASC" 45 | end 46 | 47 | def regexp 48 | /#{slug}#{sequence_separator}(\d+)\z/ 49 | end 50 | 51 | def sequential_slug_matcher 52 | # Underscores (matching a single character) and percent signs (matching 53 | # any number of characters) need to be escaped. While this looks like 54 | # an excessive number of backslashes, it is correct. 55 | "#{slug}#{sequence_separator}".gsub(/[_%]/, '\\\\\&') + "%" 56 | end 57 | 58 | def slug_conflicts 59 | scope 60 | .where(conflict_query, slug, sequential_slug_matcher) 61 | .order(Arel.sql(ordering_query)).pluck(Arel.sql(slug_column)) 62 | end 63 | 64 | def sql_length 65 | /sqlserver/i.match?(scope.connection.adapter_name) ? "LEN" : "LENGTH" 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/friendly_id/simple_i18n.rb: -------------------------------------------------------------------------------- 1 | require "i18n" 2 | 3 | module FriendlyId 4 | # @guide begin 5 | # 6 | # ## Translating Slugs Using Simple I18n 7 | # 8 | # The {FriendlyId::SimpleI18n SimpleI18n} module adds very basic i18n support to 9 | # FriendlyId. 10 | # 11 | # In order to use this module, your model must have a slug column for each locale. 12 | # By default FriendlyId looks for columns named, for example, "slug_en", 13 | # "slug_es", "slug_pt_br", etc. The first part of the name can be configured by 14 | # passing the `:slug_column` option if you choose. Note that the column for the 15 | # default locale must also include the locale in its name. 16 | # 17 | # This module is most suitable to applications that need to support few locales. 18 | # If you need to support two or more locales, you may wish to use the 19 | # friendly_id_globalize gem instead. 20 | # 21 | # ### Example migration 22 | # 23 | # def self.up 24 | # create_table :posts do |t| 25 | # t.string :title 26 | # t.string :slug_en 27 | # t.string :slug_es 28 | # t.string :slug_pt_br 29 | # t.text :body 30 | # end 31 | # add_index :posts, :slug_en 32 | # add_index :posts, :slug_es 33 | # add_index :posts, :slug_pt_br 34 | # end 35 | # 36 | # ### Finds 37 | # 38 | # Finds will take into consideration the current locale: 39 | # 40 | # I18n.locale = :es 41 | # Post.friendly.find("la-guerra-de-las-galaxias") 42 | # I18n.locale = :en 43 | # Post.friendly.find("star-wars") 44 | # I18n.locale = :"pt-BR" 45 | # Post.friendly.find("guerra-das-estrelas") 46 | # 47 | # To find a slug by an explicit locale, perform the find inside a block 48 | # passed to I18n's `with_locale` method: 49 | # 50 | # I18n.with_locale(:es) do 51 | # Post.friendly.find("la-guerra-de-las-galaxias") 52 | # end 53 | # 54 | # ### Creating Records 55 | # 56 | # When new records are created, the slug is generated for the current locale only. 57 | # 58 | # ### Translating Slugs 59 | # 60 | # To translate an existing record's friendly_id, use 61 | # {FriendlyId::SimpleI18n::Model#set_friendly_id}. This will ensure that the slug 62 | # you add is properly escaped, transliterated and sequenced: 63 | # 64 | # post = Post.create :name => "Star Wars" 65 | # post.set_friendly_id("La guerra de las galaxias", :es) 66 | # 67 | # If you don't pass in a locale argument, FriendlyId::SimpleI18n will just use the 68 | # current locale: 69 | # 70 | # I18n.with_locale(:es) do 71 | # post.set_friendly_id("La guerra de las galaxias") 72 | # end 73 | # 74 | # @guide end 75 | module SimpleI18n 76 | # FriendlyId::Config.use will invoke this method when present, to allow 77 | # loading dependent modules prior to overriding them when necessary. 78 | def self.setup(model_class) 79 | model_class.friendly_id_config.use :slugged 80 | end 81 | 82 | def self.included(model_class) 83 | model_class.class_eval do 84 | friendly_id_config.class.send :include, Configuration 85 | include Model 86 | end 87 | end 88 | 89 | module Model 90 | def set_friendly_id(text, locale = nil) 91 | I18n.with_locale(locale || I18n.locale) do 92 | set_slug(normalize_friendly_id(text)) 93 | end 94 | end 95 | 96 | def slug=(value) 97 | super 98 | write_attribute friendly_id_config.slug_column, value 99 | end 100 | end 101 | 102 | module Configuration 103 | def slug_column 104 | "#{super}_#{locale_suffix}" 105 | end 106 | 107 | private 108 | 109 | def locale_suffix 110 | I18n.locale.to_s.underscore 111 | end 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /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 | end 16 | end 17 | -------------------------------------------------------------------------------- /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 | def initialize(scope, config) 6 | @scope = scope 7 | @config = config 8 | end 9 | 10 | def available?(slug) 11 | if @config.uses?(::FriendlyId::Reserved) && @config.reserved_words.present? && @config.treat_reserved_as_conflict 12 | return false if @config.reserved_words.include?(slug) 13 | end 14 | 15 | !@scope.exists_by_friendly_id?(slug) 16 | end 17 | 18 | def generate(candidates) 19 | candidates.each { |c| return c if available?(c) } 20 | nil 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/friendly_id/slugged.rb: -------------------------------------------------------------------------------- 1 | require "friendly_id/slug_generator" 2 | require "friendly_id/candidates" 3 | 4 | module FriendlyId 5 | # @guide begin 6 | # 7 | # ## Slugged Models 8 | # 9 | # FriendlyId can use a separate column to store slugs for models which require 10 | # some text processing. 11 | # 12 | # For example, blog applications typically use a post title to provide the basis 13 | # of a search engine friendly URL. Such identifiers typically lack uppercase 14 | # characters, use ASCII to approximate UTF-8 characters, and strip out other 15 | # characters which may make them aesthetically unappealing or error-prone when 16 | # used in a URL. 17 | # 18 | # class Post < ActiveRecord::Base 19 | # extend FriendlyId 20 | # friendly_id :title, :use => :slugged 21 | # end 22 | # 23 | # @post = Post.create(:title => "This is the first post!") 24 | # @post.friendly_id # returns "this-is-the-first-post" 25 | # redirect_to @post # the URL will be /posts/this-is-the-first-post 26 | # 27 | # In general, use slugs by default unless you know for sure you don't need them. 28 | # To activate the slugging functionality, use the {FriendlyId::Slugged} module. 29 | # 30 | # FriendlyId will generate slugs from a method or column that you specify, and 31 | # store them in a field in your model. By default, this field must be named 32 | # `:slug`, though you may change this using the 33 | # {FriendlyId::Slugged::Configuration#slug_column slug_column} configuration 34 | # option. You should add an index to this column, and in most cases, make it 35 | # unique. You may also wish to constrain it to NOT NULL, but this depends on your 36 | # app's behavior and requirements. 37 | # 38 | # ### Example Setup 39 | # 40 | # # your model 41 | # class Post < ActiveRecord::Base 42 | # extend FriendlyId 43 | # friendly_id :title, :use => :slugged 44 | # validates_presence_of :title, :slug, :body 45 | # end 46 | # 47 | # # a migration 48 | # class CreatePosts < ActiveRecord::Migration 49 | # def self.up 50 | # create_table :posts do |t| 51 | # t.string :title, :null => false 52 | # t.string :slug, :null => false 53 | # t.text :body 54 | # end 55 | # 56 | # add_index :posts, :slug, :unique => true 57 | # end 58 | # 59 | # def self.down 60 | # drop_table :posts 61 | # end 62 | # end 63 | # 64 | # ### Working With Slugs 65 | # 66 | # #### Formatting 67 | # 68 | # By default, FriendlyId uses Active Support's 69 | # [parameterize](http://api.rubyonrails.org/classes/ActiveSupport/Inflector.html#method-i-parameterize) 70 | # method to create slugs. This method will intelligently replace spaces with 71 | # dashes, and Unicode Latin characters with ASCII approximations: 72 | # 73 | # movie = Movie.create! :title => "Der Preis fürs Überleben" 74 | # movie.slug #=> "der-preis-furs-uberleben" 75 | # 76 | # #### Column or Method? 77 | # 78 | # FriendlyId always uses a method as the basis of the slug text - not a column. At 79 | # first glance, this may sound confusing, but remember that Active Record provides 80 | # methods for each column in a model's associated table, and that's what 81 | # FriendlyId uses. 82 | # 83 | # Here's an example of a class that uses a custom method to generate the slug: 84 | # 85 | # class Person < ActiveRecord::Base 86 | # extend FriendlyId 87 | # friendly_id :name_and_location, use: :slugged 88 | # 89 | # def name_and_location 90 | # "#{name} from #{location}" 91 | # end 92 | # end 93 | # 94 | # bob = Person.create! :name => "Bob Smith", :location => "New York City" 95 | # bob.friendly_id #=> "bob-smith-from-new-york-city" 96 | # 97 | # FriendlyId refers to this internally as the "base" method. 98 | # 99 | # #### Uniqueness 100 | # 101 | # When you try to insert a record that would generate a duplicate friendly id, 102 | # FriendlyId will append a UUID to the generated slug to ensure uniqueness: 103 | # 104 | # car = Car.create :title => "Peugeot 206" 105 | # car2 = Car.create :title => "Peugeot 206" 106 | # 107 | # car.friendly_id #=> "peugeot-206" 108 | # car2.friendly_id #=> "peugeot-206-f9f3789a-daec-4156-af1d-fab81aa16ee5" 109 | # 110 | # Previous versions of FriendlyId appended a numeric sequence to make slugs 111 | # unique, but this was removed to simplify using FriendlyId in concurrent code. 112 | # 113 | # #### Candidates 114 | # 115 | # Since UUIDs are ugly, FriendlyId provides a "slug candidates" functionality to 116 | # let you specify alternate slugs to use in the event the one you want to use is 117 | # already taken. For example: 118 | # 119 | # class Restaurant < ActiveRecord::Base 120 | # extend FriendlyId 121 | # friendly_id :slug_candidates, use: :slugged 122 | # 123 | # # Try building a slug based on the following fields in 124 | # # increasing order of specificity. 125 | # def slug_candidates 126 | # [ 127 | # :name, 128 | # [:name, :city], 129 | # [:name, :street, :city], 130 | # [:name, :street_number, :street, :city] 131 | # ] 132 | # end 133 | # end 134 | # 135 | # r1 = Restaurant.create! name: 'Plaza Diner', city: 'New Paltz' 136 | # r2 = Restaurant.create! name: 'Plaza Diner', city: 'Kingston' 137 | # 138 | # r1.friendly_id #=> 'plaza-diner' 139 | # r2.friendly_id #=> 'plaza-diner-kingston' 140 | # 141 | # To use candidates, make your FriendlyId base method return an array. The 142 | # method need not be named `slug_candidates`; it can be anything you want. The 143 | # array may contain any combination of symbols, strings, procs or lambdas and 144 | # will be evaluated lazily and in order. If you include symbols, FriendlyId will 145 | # invoke a method on your model class with the same name. Strings will be 146 | # interpreted literally. Procs and lambdas will be called and their return values 147 | # used as the basis of the friendly id. If none of the candidates can generate a 148 | # unique slug, then FriendlyId will append a UUID to the first candidate as a 149 | # last resort. 150 | # 151 | # #### Sequence Separator 152 | # 153 | # By default, FriendlyId uses a dash to separate the slug from a sequence. 154 | # 155 | # You can change this with the {FriendlyId::Slugged::Configuration#sequence_separator 156 | # sequence_separator} configuration option. 157 | # 158 | # #### Providing Your Own Slug Processing Method 159 | # 160 | # You can override {FriendlyId::Slugged#normalize_friendly_id} in your model for 161 | # total control over the slug format. It will be invoked for any generated slug, 162 | # whether for a single slug or for slug candidates. 163 | # 164 | # #### Deciding When to Generate New Slugs 165 | # 166 | # As of FriendlyId 5.0, slugs are only generated when the `slug` field is nil. If 167 | # you want a slug to be regenerated,set the slug field to nil: 168 | # 169 | # restaurant.friendly_id # joes-diner 170 | # restaurant.name = "The Plaza Diner" 171 | # restaurant.save! 172 | # restaurant.friendly_id # joes-diner 173 | # restaurant.slug = nil 174 | # restaurant.save! 175 | # restaurant.friendly_id # the-plaza-diner 176 | # 177 | # You can also override the 178 | # {FriendlyId::Slugged#should_generate_new_friendly_id?} method, which lets you 179 | # control exactly when new friendly ids are set: 180 | # 181 | # class Post < ActiveRecord::Base 182 | # extend FriendlyId 183 | # friendly_id :title, :use => :slugged 184 | # 185 | # def should_generate_new_friendly_id? 186 | # title_changed? 187 | # end 188 | # end 189 | # 190 | # If you want to extend the default behavior but add your own conditions, 191 | # don't forget to invoke `super` from your implementation: 192 | # 193 | # class Category < ActiveRecord::Base 194 | # extend FriendlyId 195 | # friendly_id :name, :use => :slugged 196 | # 197 | # def should_generate_new_friendly_id? 198 | # name_changed? || super 199 | # end 200 | # end 201 | # 202 | # #### Locale-specific Transliterations 203 | # 204 | # Active Support's `parameterize` uses 205 | # [transliterate](http://api.rubyonrails.org/classes/ActiveSupport/Inflector.html#method-i-transliterate), 206 | # which in turn can use I18n's transliteration rules to consider the current 207 | # locale when replacing Latin characters: 208 | # 209 | # # config/locales/de.yml 210 | # de: 211 | # i18n: 212 | # transliterate: 213 | # rule: 214 | # ü: "ue" 215 | # ö: "oe" 216 | # etc... 217 | # 218 | # movie = Movie.create! :title => "Der Preis fürs Überleben" 219 | # movie.slug #=> "der-preis-fuers-ueberleben" 220 | # 221 | # This functionality was in fact taken from earlier versions of FriendlyId. 222 | # 223 | # #### Gotchas: Common Problems 224 | # 225 | # FriendlyId uses a before_validation callback to generate and set the slug. This 226 | # means that if you create two model instances before saving them, it's possible 227 | # they will generate the same slug, and the second save will fail. 228 | # 229 | # This can happen in two fairly normal cases: the first, when a model using nested 230 | # attributes creates more than one record for a model that uses friendly_id. The 231 | # second, in concurrent code, either in threads or multiple processes. 232 | # 233 | # To solve the nested attributes issue, I recommend simply avoiding them when 234 | # creating more than one nested record for a model that uses FriendlyId. See [this 235 | # Github issue](https://github.com/norman/friendly_id/issues/185) for discussion. 236 | # 237 | # @guide end 238 | module Slugged 239 | # Sets up behavior and configuration options for FriendlyId's slugging 240 | # feature. 241 | def self.included(model_class) 242 | model_class.friendly_id_config.instance_eval do 243 | self.class.send :include, Configuration 244 | self.slug_generator_class ||= SlugGenerator 245 | defaults[:slug_column] ||= "slug" 246 | defaults[:sequence_separator] ||= "-" 247 | end 248 | model_class.before_validation :set_slug 249 | model_class.before_save :set_slug 250 | model_class.after_validation :unset_slug_if_invalid 251 | end 252 | 253 | # Process the given value to make it suitable for use as a slug. 254 | # 255 | # This method is not intended to be invoked directly; FriendlyId uses it 256 | # internally to process strings into slugs. 257 | # 258 | # However, if FriendlyId's default slug generation doesn't suit your needs, 259 | # you can override this method in your model class to control exactly how 260 | # slugs are generated. 261 | # 262 | # ### Example 263 | # 264 | # class Person < ActiveRecord::Base 265 | # extend FriendlyId 266 | # friendly_id :name_and_location 267 | # 268 | # def name_and_location 269 | # "#{name} from #{location}" 270 | # end 271 | # 272 | # # Use default slug, but upper case and with underscores 273 | # def normalize_friendly_id(string) 274 | # super.upcase.gsub("-", "_") 275 | # end 276 | # end 277 | # 278 | # bob = Person.create! :name => "Bob Smith", :location => "New York City" 279 | # bob.friendly_id #=> "BOB_SMITH_FROM_NEW_YORK_CITY" 280 | # 281 | # ### More Resources 282 | # 283 | # You might want to look into Babosa[https://github.com/norman/babosa], 284 | # which is the slugging library used by FriendlyId prior to version 4, which 285 | # offers some specialized functionality missing from Active Support. 286 | # 287 | # @param [#to_s] value The value used as the basis of the slug. 288 | # @return The candidate slug text, without a sequence. 289 | def normalize_friendly_id(value) 290 | value = value.to_s.parameterize 291 | value = value[0...friendly_id_config.slug_limit] if friendly_id_config.slug_limit 292 | value 293 | end 294 | 295 | # Whether to generate a new slug. 296 | # 297 | # You can override this method in your model if, for example, you only want 298 | # slugs to be generated once, and then never updated. 299 | def should_generate_new_friendly_id? 300 | send(friendly_id_config.slug_column).nil? && !send(friendly_id_config.base).nil? 301 | end 302 | 303 | # Public: Resolve conflicts. 304 | # 305 | # This method adds UUID to first candidate and truncates (if `slug_limit` is set). 306 | # 307 | # Examples: 308 | # 309 | # resolve_friendly_id_conflict(['12345']) 310 | # # => '12345-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' 311 | # 312 | # FriendlyId.defaults { |config| config.slug_limit = 40 } 313 | # resolve_friendly_id_conflict(['12345']) 314 | # # => '123-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' 315 | # 316 | # candidates - the Array with candidates. 317 | # 318 | # Returns the String with new slug. 319 | def resolve_friendly_id_conflict(candidates) 320 | uuid = SecureRandom.uuid 321 | [ 322 | apply_slug_limit(candidates.first, uuid), 323 | uuid 324 | ].compact.join(friendly_id_config.sequence_separator) 325 | end 326 | 327 | # Private: Apply slug limit to candidate. 328 | # 329 | # candidate - the String with candidate. 330 | # uuid - the String with UUID. 331 | # 332 | # Return the String with truncated candidate. 333 | def apply_slug_limit(candidate, uuid) 334 | return candidate unless candidate && friendly_id_config.slug_limit 335 | 336 | candidate[0...candidate_limit(uuid)] 337 | end 338 | private :apply_slug_limit 339 | 340 | # Private: Get max length of candidate. 341 | # 342 | # uuid - the String with UUID. 343 | # 344 | # Returns the Integer with max length. 345 | def candidate_limit(uuid) 346 | [ 347 | friendly_id_config.slug_limit - uuid.size - friendly_id_config.sequence_separator.size, 348 | 0 349 | ].max 350 | end 351 | private :candidate_limit 352 | 353 | # Sets the slug. 354 | def set_slug(normalized_slug = nil) 355 | if should_generate_new_friendly_id? 356 | candidates = FriendlyId::Candidates.new(self, normalized_slug || send(friendly_id_config.base)) 357 | slug = slug_generator.generate(candidates) || resolve_friendly_id_conflict(candidates) 358 | send "#{friendly_id_config.slug_column}=", slug 359 | end 360 | end 361 | private :set_slug 362 | 363 | def scope_for_slug_generator 364 | scope = self.class.base_class.unscoped 365 | scope = scope.friendly unless scope.respond_to?(:exists_by_friendly_id?) 366 | primary_key_name = self.class.primary_key 367 | scope.where(self.class.base_class.arel_table[primary_key_name].not_eq(send(primary_key_name))) 368 | end 369 | private :scope_for_slug_generator 370 | 371 | def slug_generator 372 | friendly_id_config.slug_generator_class.new(scope_for_slug_generator, friendly_id_config) 373 | end 374 | private :slug_generator 375 | 376 | def unset_slug_if_invalid 377 | if errors.key?(friendly_id_config.query_field) && attribute_changed?(friendly_id_config.query_field.to_s) 378 | diff = changes[friendly_id_config.query_field] 379 | send "#{friendly_id_config.slug_column}=", diff.first 380 | end 381 | end 382 | private :unset_slug_if_invalid 383 | 384 | # This module adds the `:slug_column`, and `:slug_limit`, and `:sequence_separator`, 385 | # and `:slug_generator_class` configuration options to 386 | # {FriendlyId::Configuration FriendlyId::Configuration}. 387 | module Configuration 388 | attr_writer :slug_column, :slug_limit, :sequence_separator 389 | attr_accessor :slug_generator_class 390 | 391 | # Makes FriendlyId use the slug column for querying. 392 | # @return String The slug column. 393 | def query_field 394 | slug_column 395 | end 396 | 397 | # The string used to separate a slug base from a numeric sequence. 398 | # 399 | # You can change the default separator by setting the 400 | # {FriendlyId::Slugged::Configuration#sequence_separator 401 | # sequence_separator} configuration option. 402 | # @return String The sequence separator string. Defaults to "`-`". 403 | def sequence_separator 404 | @sequence_separator ||= defaults[:sequence_separator] 405 | end 406 | 407 | # The column that will be used to store the generated slug. 408 | def slug_column 409 | @slug_column ||= defaults[:slug_column] 410 | end 411 | 412 | # The limit that will be used for slug. 413 | def slug_limit 414 | @slug_limit ||= defaults[:slug_limit] 415 | end 416 | end 417 | end 418 | end 419 | -------------------------------------------------------------------------------- /lib/friendly_id/version.rb: -------------------------------------------------------------------------------- 1 | module FriendlyId 2 | VERSION = "5.5.1".freeze 3 | end 4 | -------------------------------------------------------------------------------- /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/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 | test "friendly_id should accept a block" do 32 | klass = Class.new(ActiveRecord::Base) do 33 | self.abstract_class = true 34 | extend FriendlyId 35 | friendly_id :foo do |config| 36 | config.use :slugged 37 | config.base = :foo 38 | config.slug_column = :bar 39 | end 40 | end 41 | assert klass < FriendlyId::Slugged 42 | assert_equal :foo, klass.friendly_id_config.base 43 | assert_equal :bar, klass.friendly_id_config.slug_column 44 | end 45 | 46 | test "the block passed to friendly_id should be evaluated before arguments" do 47 | klass = Class.new(ActiveRecord::Base) do 48 | self.abstract_class = true 49 | extend FriendlyId 50 | friendly_id :foo do |config| 51 | config.base = :bar 52 | end 53 | end 54 | assert_equal :foo, klass.friendly_id_config.base 55 | end 56 | 57 | test "should allow defaults to be set via a block" do 58 | FriendlyId.defaults do |config| 59 | config.base = :foo 60 | end 61 | klass = Class.new(ActiveRecord::Base) do 62 | self.abstract_class = true 63 | extend FriendlyId 64 | end 65 | assert_equal :foo, klass.friendly_id_config.base 66 | ensure 67 | FriendlyId.instance_variable_set :@defaults, nil 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /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 do 18 | yield 19 | 20 | raise ActiveRecord::Rollback 21 | end 22 | end 23 | 24 | class Array 25 | def rand 26 | self[Kernel.rand(length)] 27 | end 28 | end 29 | 30 | Book = Class.new ActiveRecord::Base 31 | 32 | class Journalist < ActiveRecord::Base 33 | extend FriendlyId 34 | friendly_id :name, use: :slugged 35 | end 36 | 37 | class Manual < ActiveRecord::Base 38 | extend FriendlyId 39 | friendly_id :name, use: :history 40 | end 41 | 42 | class Restaurant < ActiveRecord::Base 43 | extend FriendlyId 44 | friendly_id :name, use: :finders 45 | end 46 | 47 | BOOKS = [] 48 | JOURNALISTS = [] 49 | MANUALS = [] 50 | RESTAURANTS = [] 51 | 52 | 100.times do 53 | name = FFaker::Name.name 54 | BOOKS << (Book.create! name: name).id 55 | JOURNALISTS << (Journalist.create! name: name).friendly_id 56 | MANUALS << (Manual.create! name: name).friendly_id 57 | RESTAURANTS << (Restaurant.create! name: name).friendly_id 58 | end 59 | 60 | ActiveRecord::Base.connection.execute "UPDATE manuals SET slug = NULL" 61 | 62 | Benchmark.bmbm do |x| 63 | x.report "ActiveRecord: where.first" do 64 | N.times { Book.where(id: BOOKS.rand).first } 65 | end 66 | 67 | x.report "ActiveRecord: where.take" do 68 | N.times { Book.where(id: BOOKS.rand).take } 69 | end 70 | 71 | x.report "ActiveRecord: find" do 72 | N.times { Book.find BOOKS.rand } 73 | end 74 | 75 | x.report "ActiveRecord: find_by(:id)" do 76 | N.times { Book.find_by(id: BOOKS.rand) } 77 | end 78 | 79 | x.report "ActiveRecord: find_by(:slug)" do 80 | N.times { Restaurant.find_by(slug: RESTAURANTS.rand) } 81 | end 82 | 83 | x.report "FriendlyId: find (in-table slug w/ finders)" do 84 | N.times { Restaurant.find RESTAURANTS.rand } 85 | end 86 | 87 | x.report "FriendlyId: friendly.find (in-table slug)" do 88 | N.times { Restaurant.friendly.find RESTAURANTS.rand } 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /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/candidates_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | class CandidatesTest < TestCaseClass 4 | include FriendlyId::Test 5 | 6 | class Airport 7 | def initialize(code) 8 | @code = code 9 | end 10 | attr_reader :code 11 | alias_method :to_s, :code 12 | end 13 | 14 | class City < ActiveRecord::Base 15 | extend FriendlyId 16 | friendly_id :slug_candidates, use: :slugged 17 | alias_attribute :slug_candidates, :name 18 | end 19 | 20 | def model_class 21 | City 22 | end 23 | 24 | def with_instances_of(klass = model_class, &block) 25 | transaction do 26 | city1 = klass.create! name: "New York", code: "JFK" 27 | city2 = klass.create! name: "New York", code: "EWR" 28 | yield city1, city2 29 | end 30 | end 31 | alias_method :with_instances, :with_instances_of 32 | 33 | test "resolves conflict with candidate" do 34 | with_instances do |city1, city2| 35 | assert_equal "new-york", city1.slug 36 | assert_match(/\Anew-york-([a-z0-9]+-){4}[a-z0-9]+\z/, city2.slug) 37 | end 38 | end 39 | 40 | test "accepts candidate as symbol" do 41 | klass = Class.new model_class do 42 | def slug_candidates 43 | :name 44 | end 45 | end 46 | with_instances_of klass do |_, city| 47 | assert_match(/\Anew-york-([a-z0-9]+-){4}[a-z0-9]+\z/, city.slug) 48 | end 49 | end 50 | 51 | test "accepts multiple candidates" do 52 | klass = Class.new model_class do 53 | def slug_candidates 54 | [name, code] 55 | end 56 | end 57 | with_instances_of klass do |_, city| 58 | assert_equal "ewr", city.slug 59 | end 60 | end 61 | 62 | test "ignores blank candidate" do 63 | klass = Class.new model_class do 64 | def slug_candidates 65 | [name, ""] 66 | end 67 | end 68 | with_instances_of klass do |_, city| 69 | assert_match(/\Anew-york-([a-z0-9]+-){4}[a-z0-9]+\z/, city.slug) 70 | end 71 | end 72 | 73 | test "ignores nil candidate" do 74 | klass = Class.new model_class do 75 | def slug_candidates 76 | [name, nil] 77 | end 78 | end 79 | with_instances_of klass do |_, city| 80 | assert_match(/\Anew-york-([a-z0-9]+-){4}[a-z0-9]+\z/, city.slug) 81 | end 82 | end 83 | 84 | test "accepts candidate with nested array" do 85 | klass = Class.new model_class do 86 | def slug_candidates 87 | [name, [name, code]] 88 | end 89 | end 90 | with_instances_of klass do |_, city| 91 | assert_equal "new-york-ewr", city.slug 92 | end 93 | end 94 | 95 | test "accepts candidate with lambda" do 96 | klass = Class.new City do 97 | def slug_candidates 98 | [name, [name, -> { rand 1000 }]] 99 | end 100 | end 101 | with_instances_of klass do |_, city| 102 | assert_match(/\Anew-york-\d{,3}\z/, city.friendly_id) 103 | end 104 | end 105 | 106 | test "accepts candidate with object" do 107 | klass = Class.new City do 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 | end 143 | -------------------------------------------------------------------------------- /test/configuration_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | class ConfigurationTest < TestCaseClass 4 | include FriendlyId::Test 5 | 6 | def setup 7 | @model_class = Class.new(ActiveRecord::Base) do 8 | self.abstract_class = true 9 | end 10 | end 11 | 12 | test "should set model class on initialization" do 13 | config = FriendlyId::Configuration.new @model_class 14 | assert_equal @model_class, config.model_class 15 | end 16 | 17 | test "should set options on initialization if present" do 18 | config = FriendlyId::Configuration.new @model_class, base: "hello" 19 | assert_equal "hello", config.base 20 | end 21 | 22 | test "should raise error if passed unrecognized option" do 23 | assert_raises NoMethodError do 24 | FriendlyId::Configuration.new @model_class, foo: "bar" 25 | end 26 | end 27 | 28 | test "#use should accept a name that resolves to a module" do 29 | refute @model_class < FriendlyId::Slugged 30 | @model_class.class_eval do 31 | extend FriendlyId 32 | friendly_id :hello, use: :slugged 33 | end 34 | assert @model_class < FriendlyId::Slugged 35 | end 36 | 37 | test "#use should accept a module" do 38 | my_module = Module.new 39 | refute @model_class < my_module 40 | @model_class.class_eval do 41 | extend FriendlyId 42 | friendly_id :hello, use: my_module 43 | end 44 | assert @model_class < my_module 45 | end 46 | 47 | test "#base should optionally set a value" do 48 | config = FriendlyId::Configuration.new @model_class 49 | assert_nil config.base 50 | config.base = "foo" 51 | assert_equal "foo", config.base 52 | end 53 | 54 | test "#base can set the value to nil" do 55 | config = FriendlyId::Configuration.new @model_class 56 | config.base "foo" 57 | config.base nil 58 | assert_nil config.base 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /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 | include FriendlyId::Test 16 | include FriendlyId::Test::Shared::Core 17 | 18 | def model_class 19 | Author 20 | end 21 | 22 | test "models don't use friendly_id by default" do 23 | assert !Class.new(ActiveRecord::Base) { 24 | self.abstract_class = true 25 | }.respond_to?(:friendly_id) 26 | end 27 | 28 | test "model classes should have a friendly id config" do 29 | assert model_class.friendly_id(:name).friendly_id_config 30 | end 31 | 32 | test "instances should have a friendly id" do 33 | with_instance_of(model_class) { |record| assert record.friendly_id } 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/databases.yml: -------------------------------------------------------------------------------- 1 | mysql: 2 | adapter: mysql2 3 | database: friendly_id_test 4 | username: root 5 | password: <%= ENV['MYSQL_PASSWORD'] %> 6 | host: 127.0.0.1 7 | port: 3306 8 | encoding: utf8 9 | 10 | postgres: 11 | adapter: postgresql 12 | host: <%= ENV.fetch('PGHOST', 'localhost') %> 13 | port: <%= ENV.fetch('PGPORT', '5432') %> 14 | username: <%= ENV.fetch('PGUSER', 'postgres') %> 15 | password: <%= ENV.fetch('PGPASSWORD', 'postgres') %> 16 | database: friendly_id_test 17 | encoding: utf8 18 | 19 | sqlite3: 20 | adapter: sqlite3 21 | database: ":memory:" 22 | encoding: utf8 23 | -------------------------------------------------------------------------------- /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 | include FriendlyId::Test 12 | 13 | def model_class 14 | JournalistWithFriendlyFinders 15 | end 16 | 17 | test "should find records with finders as class methods" do 18 | with_instance_of(model_class) do |record| 19 | assert model_class.find(record.friendly_id) 20 | end 21 | end 22 | 23 | test "should find records with finders on relations" do 24 | with_instance_of(model_class) do |record| 25 | assert model_class.existing.find(record.friendly_id) 26 | end 27 | end 28 | 29 | test "allows nil with allow_nil: true" do 30 | with_instance_of(model_class) do |record| 31 | assert_nil model_class.find("foo", allow_nil: true) 32 | end 33 | end 34 | 35 | test "allows nil on relations with allow_nil: true" do 36 | with_instance_of(model_class) do |record| 37 | assert_nil model_class.existing.find("foo", allow_nil: true) 38 | end 39 | end 40 | 41 | test "allows nil with a bad primary key ID and allow_nil: true" do 42 | with_instance_of(model_class) do |record| 43 | assert_nil model_class.find(0, allow_nil: true) 44 | end 45 | end 46 | 47 | test "allows nil on relations with a bad primary key ID and allow_nil: true" do 48 | with_instance_of(model_class) do |record| 49 | assert_nil model_class.existing.find(0, allow_nil: true) 50 | end 51 | end 52 | 53 | test "allows nil with a bad potential primary key ID and allow_nil: true" do 54 | with_instance_of(model_class) do |record| 55 | assert_nil model_class.find("0", allow_nil: true) 56 | end 57 | end 58 | 59 | test "allows nil on relations with a bad potential primary key ID and allow_nil: true" do 60 | with_instance_of(model_class) do |record| 61 | assert_nil model_class.existing.find("0", allow_nil: true) 62 | end 63 | end 64 | 65 | test "allows nil with nil ID and allow_nil: true" do 66 | with_instance_of(model_class) do |record| 67 | assert_nil model_class.find(nil, allow_nil: true) 68 | end 69 | end 70 | 71 | test "allows nil on relations with nil ID and allow_nil: true" do 72 | with_instance_of(model_class) do |record| 73 | assert_nil model_class.existing.find(nil, allow_nil: true) 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /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 | tests FriendlyIdGenerator 7 | destination File.expand_path("../../tmp", __FILE__) 8 | 9 | setup :prepare_destination 10 | 11 | test "should generate a migration" do 12 | run_generator 13 | assert_migration "db/migrate/create_friendly_id_slugs" 14 | ensure 15 | FileUtils.rm_rf destination_root 16 | end 17 | 18 | test "should skip the migration when told to do so" do 19 | run_generator ["--skip-migration"] 20 | assert_no_migration "db/migrate/create_friendly_id_slugs" 21 | ensure 22 | FileUtils.rm_rf destination_root 23 | end 24 | 25 | test "should generate an initializer" do 26 | run_generator 27 | assert_file "config/initializers/friendly_id.rb" 28 | ensure 29 | FileUtils.rm_rf destination_root 30 | end 31 | 32 | test "should skip the initializer when told to do so" do 33 | run_generator ["--skip-initializer"] 34 | assert_no_file "config/initializers/friendly_id.rb" 35 | ensure 36 | FileUtils.rm_rf destination_root 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /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/minitest" 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 | def self.included(base) 49 | if Minitest.respond_to?(:autorun) 50 | Minitest.autorun 51 | else 52 | require "minitest/autorun" 53 | end 54 | rescue LoadError 55 | end 56 | 57 | def transaction 58 | ActiveRecord::Base.transaction do 59 | yield 60 | 61 | raise ActiveRecord::Rollback 62 | end 63 | end 64 | 65 | def with_instance_of(*args) 66 | model_class = args.shift 67 | args[0] ||= {name: "a b c"} 68 | transaction { yield model_class.create!(*args) } 69 | end 70 | 71 | module Database 72 | extend self 73 | 74 | def connect 75 | version = ActiveRecord::VERSION::STRING 76 | engine = begin 77 | RUBY_ENGINE 78 | rescue 79 | "ruby" 80 | end 81 | 82 | ActiveRecord::Base.establish_connection config[driver] 83 | message = "Using #{engine} #{RUBY_VERSION} AR #{version} with #{driver}" 84 | 85 | puts "-" * 72 86 | if in_memory? 87 | ActiveRecord::Migration.verbose = false 88 | Schema.migrate :up 89 | puts "#{message} (in-memory)" 90 | else 91 | puts message 92 | end 93 | end 94 | 95 | def config 96 | @config ||= YAML.safe_load( 97 | ERB.new( 98 | File.read(File.expand_path("../databases.yml", __FILE__)) 99 | ).result 100 | ) 101 | end 102 | 103 | def driver 104 | db_driver = ENV.fetch("DB", "sqlite3").downcase 105 | db_driver = "postgres" if %w[postgresql pg].include?(db_driver) 106 | db_driver 107 | end 108 | 109 | def in_memory? 110 | config[driver]["database"] == ":memory:" 111 | end 112 | end 113 | end 114 | end 115 | 116 | class Module 117 | def test(name, &block) 118 | define_method("test_#{name.gsub(/[^a-z0-9']/i, "_")}".to_sym, &block) 119 | end 120 | end 121 | 122 | require "schema" 123 | require "shared" 124 | FriendlyId::Test::Database.connect 125 | at_exit { ActiveRecord::Base.connection.disconnect! } 126 | -------------------------------------------------------------------------------- /test/history_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | class HistoryTest < TestCaseClass 4 | include FriendlyId::Test 5 | include FriendlyId::Test::Shared::Core 6 | 7 | class Manual < ActiveRecord::Base 8 | extend FriendlyId 9 | friendly_id :name, use: [:slugged, :history] 10 | end 11 | 12 | def model_class 13 | Manual 14 | end 15 | 16 | test "should insert record in slugs table on create" do 17 | with_instance_of(model_class) { |record| assert record.slugs.any? } 18 | end 19 | 20 | test "should not create new slug record if friendly_id is not changed" do 21 | with_instance_of(model_class) do |record| 22 | record.active = true 23 | record.save! 24 | assert_equal 1, FriendlyId::Slug.count 25 | end 26 | end 27 | 28 | test "should create new slug record when friendly_id changes" do 29 | with_instance_of(model_class) do |record| 30 | record.name = record.name + "b" 31 | record.slug = nil 32 | record.save! 33 | assert_equal 2, FriendlyId::Slug.count 34 | end 35 | end 36 | 37 | test "should be findable by old slugs" do 38 | with_instance_of(model_class) do |record| 39 | old_friendly_id = record.friendly_id 40 | record.name = record.name + "b" 41 | record.slug = nil 42 | record.save! 43 | begin 44 | assert model_class.friendly.find(old_friendly_id) 45 | assert model_class.friendly.exists?(old_friendly_id), "should exist? by old id" 46 | rescue ActiveRecord::RecordNotFound 47 | flunk "Could not find record by old id" 48 | end 49 | end 50 | end 51 | 52 | test "should create slug records on each change" do 53 | transaction do 54 | model_class.create! name: "hello" 55 | assert_equal 1, FriendlyId::Slug.count 56 | 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 | prefix = "prefix_" 157 | without_prefix = FriendlyId::Slug.table_name 158 | ActiveRecord::Base.table_name_prefix = prefix 159 | FriendlyId::Slug.reset_table_name 160 | assert_equal prefix + without_prefix, FriendlyId::Slug.table_name 161 | ensure 162 | ActiveRecord::Base.table_name_prefix = "" 163 | FriendlyId::Slug.table_name = without_prefix 164 | end 165 | end 166 | end 167 | 168 | class HistoryTestWithAutomaticSlugRegeneration < HistoryTest 169 | class Manual < ActiveRecord::Base 170 | extend FriendlyId 171 | friendly_id :name, use: [:slugged, :history] 172 | 173 | def should_generate_new_friendly_id? 174 | slug.blank? or name_changed? 175 | end 176 | end 177 | 178 | def model_class 179 | Manual 180 | end 181 | 182 | test "should allow reversion back to a previously used slug" do 183 | with_instance_of(model_class, name: "foo") do |record| 184 | record.name = "bar" 185 | record.save! 186 | assert_equal "bar", record.friendly_id 187 | record.name = "foo" 188 | record.save! 189 | assert_equal "foo", record.friendly_id 190 | end 191 | end 192 | end 193 | 194 | class DependentDestroyTest < TestCaseClass 195 | include FriendlyId::Test 196 | 197 | class FalseManual < ActiveRecord::Base 198 | self.table_name = "manuals" 199 | 200 | extend FriendlyId 201 | friendly_id :name, use: :history, dependent: false 202 | end 203 | 204 | class DefaultManual < ActiveRecord::Base 205 | self.table_name = "manuals" 206 | 207 | extend FriendlyId 208 | friendly_id :name, use: :history 209 | end 210 | 211 | test "should allow disabling of dependent destroy" do 212 | transaction do 213 | assert FriendlyId::Slug.find_by_slug("foo").nil? 214 | l = FalseManual.create! name: "foo" 215 | assert FriendlyId::Slug.find_by_slug("foo").present? 216 | l.destroy 217 | assert FriendlyId::Slug.find_by_slug("foo").present? 218 | end 219 | end 220 | 221 | test "should dependently destroy by default" do 222 | transaction do 223 | assert FriendlyId::Slug.find_by_slug("baz").nil? 224 | l = DefaultManual.create! name: "baz" 225 | assert FriendlyId::Slug.find_by_slug("baz").present? 226 | l.destroy 227 | assert FriendlyId::Slug.find_by_slug("baz").nil? 228 | end 229 | end 230 | end 231 | 232 | if ActiveRecord::VERSION::STRING >= "5.0" 233 | class HistoryTestWithParanoidDeletes < HistoryTest 234 | class ParanoidRecord < ActiveRecord::Base 235 | extend FriendlyId 236 | friendly_id :name, use: :history, dependent: false 237 | 238 | default_scope { where(deleted_at: nil) } 239 | end 240 | 241 | def model_class 242 | ParanoidRecord 243 | end 244 | 245 | test "slug should have a sluggable even when soft deleted by a library" do 246 | transaction do 247 | assert FriendlyId::Slug.find_by_slug("paranoid").nil? 248 | record = model_class.create(name: "paranoid") 249 | assert FriendlyId::Slug.find_by_slug("paranoid").present? 250 | 251 | record.update deleted_at: Time.now 252 | 253 | orphan_slug = FriendlyId::Slug.find_by_slug("paranoid") 254 | assert orphan_slug.present?, "Orphaned slug should exist" 255 | 256 | assert orphan_slug.valid?, "Errors: #{orphan_slug.errors.full_messages}" 257 | assert orphan_slug.sluggable.present?, "Orphaned slug should still find corresponding paranoid sluggable" 258 | end 259 | end 260 | end 261 | end 262 | 263 | class HistoryTestWithSti < HistoryTest 264 | class Journalist < ActiveRecord::Base 265 | extend FriendlyId 266 | friendly_id :name, use: [:slugged, :history] 267 | end 268 | 269 | class Editorialist < Journalist 270 | end 271 | 272 | def model_class 273 | Editorialist 274 | end 275 | end 276 | 277 | class HistoryTestWithFriendlyFinders < HistoryTest 278 | class Journalist < ActiveRecord::Base 279 | extend FriendlyId 280 | friendly_id :name, use: [:slugged, :finders, :history] 281 | end 282 | 283 | class Restaurant < ActiveRecord::Base 284 | extend FriendlyId 285 | belongs_to :city 286 | friendly_id :name, use: [:slugged, :history, :finders] 287 | end 288 | 289 | test "should be findable by old slugs" do 290 | [Journalist, Restaurant].each do |model_class| 291 | with_instance_of(model_class) do |record| 292 | old_friendly_id = record.friendly_id 293 | record.name = record.name + "b" 294 | record.slug = nil 295 | record.save! 296 | begin 297 | assert model_class.find(old_friendly_id) 298 | assert model_class.exists?(old_friendly_id), "should exist? by old id for #{model_class.name}" 299 | rescue ActiveRecord::RecordNotFound 300 | flunk "Could not find record by old id for #{model_class.name}" 301 | end 302 | end 303 | end 304 | end 305 | end 306 | 307 | class HistoryTestWithFindersBeforeHistory < HistoryTest 308 | class Novelist < ActiveRecord::Base 309 | has_many :novels 310 | end 311 | 312 | class Novel < ActiveRecord::Base 313 | extend FriendlyId 314 | 315 | belongs_to :novelist 316 | 317 | friendly_id :name, use: [:finders, :history] 318 | 319 | def should_generate_new_friendly_id? 320 | slug.blank? || name_changed? 321 | end 322 | end 323 | 324 | test "should be findable by old slug through has_many association" do 325 | transaction do 326 | novelist = Novelist.create!(name: "Stephen King") 327 | novel = novelist.novels.create(name: "Rita Hayworth and Shawshank Redemption") 328 | slug = novel.slug 329 | novel.name = "Shawshank Redemption" 330 | novel.save! 331 | assert_equal novel, Novel.find(slug) 332 | assert_equal novel, novelist.novels.find(slug) 333 | end 334 | end 335 | end 336 | 337 | class City < ActiveRecord::Base 338 | has_many :restaurants 339 | end 340 | 341 | class Restaurant < ActiveRecord::Base 342 | extend FriendlyId 343 | belongs_to :city 344 | friendly_id :name, use: [:scoped, :history], scope: :city 345 | end 346 | 347 | class ScopedHistoryTest < TestCaseClass 348 | include FriendlyId::Test 349 | include FriendlyId::Test::Shared::Core 350 | 351 | def model_class 352 | Restaurant 353 | end 354 | 355 | test "should find old scoped slugs" do 356 | transaction do 357 | city = City.create! 358 | with_instance_of(Restaurant) do |record| 359 | record.city = city 360 | 361 | record.name = "x" 362 | record.slug = nil 363 | record.save! 364 | 365 | record.name = "y" 366 | record.slug = nil 367 | record.save! 368 | 369 | assert_equal city.restaurants.friendly.find("x"), city.restaurants.friendly.find("y") 370 | end 371 | end 372 | end 373 | 374 | test "should consider old scoped slugs when creating slugs" do 375 | transaction do 376 | city = City.create! 377 | with_instance_of(Restaurant) do |record| 378 | record.city = city 379 | 380 | record.name = "x" 381 | record.slug = nil 382 | record.save! 383 | 384 | record.name = "y" 385 | record.slug = nil 386 | record.save! 387 | 388 | second_record = model_class.create! city: city, name: "x" 389 | assert_match(/x-.+/, second_record.friendly_id) 390 | 391 | third_record = model_class.create! city: city, name: "y" 392 | assert_match(/y-.+/, third_record.friendly_id) 393 | end 394 | end 395 | end 396 | 397 | test "should record history when scope changes" do 398 | transaction do 399 | city1 = City.create! 400 | city2 = City.create! 401 | with_instance_of(Restaurant) do |record| 402 | record.name = "x" 403 | record.slug = nil 404 | 405 | record.city = city1 406 | record.save! 407 | assert_equal("city_id:#{city1.id}", record.slugs.reload.first.scope) 408 | assert_equal("x", record.slugs.reload.first.slug) 409 | 410 | record.city = city2 411 | record.save! 412 | assert_equal("city_id:#{city2.id}", record.slugs.reload.first.scope) 413 | 414 | record.name = "y" 415 | record.slug = nil 416 | record.city = city1 417 | record.save! 418 | assert_equal("city_id:#{city1.id}", record.slugs.reload.first.scope) 419 | assert_equal("y", record.slugs.reload.first.slug) 420 | end 421 | end 422 | end 423 | 424 | test "should allow equal slugs in different scopes" do 425 | transaction do 426 | city = City.create! 427 | second_city = City.create! 428 | record = model_class.create! city: city, name: "x" 429 | second_record = model_class.create! city: second_city, name: "x" 430 | 431 | assert_equal record.slug, second_record.slug 432 | end 433 | end 434 | end 435 | -------------------------------------------------------------------------------- /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 | model_class.create! name: "123" 28 | assert model_class.friendly.exists?("123") 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/object_utils_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | class ObjectUtilsTest < TestCaseClass 4 | include FriendlyId::Test 5 | 6 | test "strings with letters are friendly_ids" do 7 | assert "a".friendly_id? 8 | end 9 | 10 | test "integers should be unfriendly ids" do 11 | assert 1.unfriendly_id? 12 | end 13 | 14 | test "numeric strings are neither friendly nor unfriendly" do 15 | assert_nil "1".friendly_id? 16 | assert_nil "1".unfriendly_id? 17 | end 18 | 19 | test "ActiveRecord::Base instances should be unfriendly_ids" do 20 | FriendlyId.mark_as_unfriendly(ActiveRecord::Base) 21 | 22 | model_class = Class.new(ActiveRecord::Base) do 23 | self.table_name = "authors" 24 | end 25 | assert model_class.new.unfriendly_id? 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/reserved_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | class ReservedTest < TestCaseClass 4 | include FriendlyId::Test 5 | 6 | class Journalist < ActiveRecord::Base 7 | extend FriendlyId 8 | friendly_id :slug_candidates, use: [:slugged, :reserved], reserved_words: %w[new edit] 9 | 10 | after_validation :move_friendly_id_error_to_name 11 | 12 | def move_friendly_id_error_to_name 13 | errors.add :name, *errors.delete(:friendly_id) if errors[:friendly_id].present? 14 | end 15 | 16 | def slug_candidates 17 | name 18 | end 19 | end 20 | 21 | def model_class 22 | Journalist 23 | end 24 | 25 | test "should reserve words" do 26 | %w[new edit NEW Edit].each do |word| 27 | transaction do 28 | assert_raises(ActiveRecord::RecordInvalid) { model_class.create! name: word } 29 | end 30 | end 31 | end 32 | 33 | test "should move friendly_id error to name" do 34 | with_instance_of(model_class) do |record| 35 | record.errors.add :name, "xxx" 36 | record.errors.add :friendly_id, "yyy" 37 | record.move_friendly_id_error_to_name 38 | assert record.errors[:name].present? && record.errors[:friendly_id].blank? 39 | assert_equal 2, record.errors.count 40 | end 41 | end 42 | 43 | test "should reject reserved candidates" do 44 | transaction do 45 | record = model_class.new(name: "new") 46 | def record.slug_candidates 47 | [:name, "foo"] 48 | end 49 | record.save! 50 | assert_equal "foo", record.friendly_id 51 | end 52 | end 53 | 54 | test "should be invalid if all candidates are reserved" do 55 | transaction do 56 | record = model_class.new(name: "new") 57 | def record.slug_candidates 58 | ["edit", "new"] 59 | end 60 | assert_raises(ActiveRecord::RecordInvalid) { record.save! } 61 | end 62 | end 63 | 64 | test "should optionally treat reserved words as conflict" do 65 | klass = Class.new(model_class) do 66 | friendly_id :slug_candidates, use: [:slugged, :reserved], reserved_words: %w[new edit], treat_reserved_as_conflict: true 67 | end 68 | 69 | with_instance_of(klass, name: "new") do |record| 70 | assert_match(/new-([0-9a-z]+-){4}[0-9a-z]+\z/, record.slug) 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /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 table_name != "novels" 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 | add_column :journalists, "slug_fr_ca", :string 73 | 74 | # This will be used to test relationships 75 | add_column :books, :author_id, :integer 76 | 77 | # Used to test :scoped and :history together 78 | add_column :restaurants, :city_id, :integer 79 | 80 | # Used to test candidates 81 | add_column :cities, :code, :string, limit: 3 82 | 83 | # Used as a non-default slug_column 84 | add_column :authors, :subdomain, :string 85 | 86 | @done = true 87 | end 88 | 89 | private 90 | 91 | def slugged_tables 92 | %w[journalists articles novelists novels manuals cities] 93 | end 94 | 95 | def paranoid_tables 96 | ["paranoid_records"] 97 | end 98 | 99 | def tables_with_uuid_primary_key 100 | ["menu_items"] 101 | end 102 | 103 | def scoped_tables 104 | ["restaurants"] 105 | end 106 | 107 | def simple_tables 108 | %w[authors books publishers] 109 | end 110 | 111 | def tables 112 | simple_tables + slugged_tables + scoped_tables + paranoid_tables 113 | end 114 | end 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /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 | include FriendlyId::Test 25 | include FriendlyId::Test::Shared::Core 26 | 27 | def model_class 28 | Novel 29 | end 30 | 31 | test "should detect scope column from belongs_to relation" do 32 | assert_equal ["publisher_id", "novelist_id"], Novel.friendly_id_config.scope_columns 33 | end 34 | 35 | test "should detect scope column from explicit column name" do 36 | model_class = Class.new(ActiveRecord::Base) do 37 | self.abstract_class = true 38 | extend FriendlyId 39 | friendly_id :empty, use: :scoped, scope: :dummy 40 | end 41 | assert_equal ["dummy"], model_class.friendly_id_config.scope_columns 42 | end 43 | 44 | test "should allow duplicate slugs outside scope" do 45 | transaction do 46 | novel1 = Novel.create! name: "a", novelist: Novelist.create!(name: "a") 47 | novel2 = Novel.create! name: "a", novelist: Novelist.create!(name: "b") 48 | assert_equal novel1.friendly_id, novel2.friendly_id 49 | end 50 | end 51 | 52 | test "should not allow duplicate slugs inside scope" do 53 | with_instance_of Novelist do |novelist| 54 | novel1 = Novel.create! name: "a", novelist: novelist 55 | novel2 = Novel.create! name: "a", novelist: novelist 56 | assert novel1.friendly_id != novel2.friendly_id 57 | end 58 | end 59 | 60 | test "should apply scope with multiple columns" do 61 | transaction do 62 | novelist = Novelist.create! name: "a" 63 | publisher = Publisher.create! name: "b" 64 | novel1 = Novel.create! name: "c", novelist: novelist, publisher: publisher 65 | novel2 = Novel.create! name: "c", novelist: novelist, publisher: Publisher.create(name: "d") 66 | novel3 = Novel.create! name: "c", novelist: Novelist.create(name: "e"), publisher: publisher 67 | novel4 = Novel.create! name: "c", novelist: novelist, publisher: publisher 68 | assert_equal novel1.friendly_id, novel2.friendly_id 69 | assert_equal novel2.friendly_id, novel3.friendly_id 70 | assert novel3.friendly_id != novel4.friendly_id 71 | end 72 | end 73 | 74 | test "should allow a record to reuse its own slug" do 75 | with_instance_of(model_class) do |record| 76 | old_id = record.friendly_id 77 | record.slug = nil 78 | record.save! 79 | assert_equal old_id, record.friendly_id 80 | end 81 | end 82 | 83 | test "should generate new slug when scope changes" do 84 | transaction do 85 | novelist = Novelist.create! name: "a" 86 | publisher = Publisher.create! name: "b" 87 | novel1 = Novel.create! name: "c", novelist: novelist, publisher: publisher 88 | novel2 = Novel.create! name: "c", novelist: novelist, publisher: Publisher.create(name: "d") 89 | assert_equal novel1.friendly_id, novel2.friendly_id 90 | novel2.publisher = publisher 91 | novel2.save! 92 | assert novel2.friendly_id != novel1.friendly_id 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /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: "Lois Lane" 51 | record_2 = model_class.create! name: "Lois Lane" 52 | 53 | assert_equal "lois-lane", record_1.attributes["strange name"] 54 | assert_equal "lois-lane-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 | Journalist = Class.new(ActiveRecord::Base) do 124 | extend FriendlyId 125 | friendly_id :name, use: [:sequentially_slugged, :history], slug_column: "strange name" 126 | end 127 | 128 | def model_class 129 | Article 130 | end 131 | 132 | test "should work with regeneration with history when slug already exists" do 133 | transaction do 134 | record1 = model_class.create! name: "Test name" 135 | record2 = model_class.create! name: "Another test name" 136 | assert_equal "test-name", record1.slug 137 | assert_equal "another-test-name", record2.slug 138 | 139 | record2.name = "Test name" 140 | record2.slug = nil 141 | record2.save! 142 | assert_equal "test-name-2", record2.slug 143 | end 144 | end 145 | 146 | test "should work with regeneration with history when 2 slugs already exists and the second is changed" do 147 | transaction do 148 | record1 = model_class.create! name: "Test name" 149 | record2 = model_class.create! name: "Test name" 150 | record3 = model_class.create! name: "Another test name" 151 | assert_equal "test-name", record1.slug 152 | assert_equal "test-name-2", record2.slug 153 | assert_equal "another-test-name", record3.slug 154 | 155 | record2.name = "One more test name" 156 | record2.slug = nil 157 | record2.save! 158 | assert_equal "one-more-test-name", record2.slug 159 | 160 | record3.name = "Test name" 161 | record3.slug = nil 162 | record3.save! 163 | assert_equal "test-name-3", record3.slug 164 | end 165 | end 166 | 167 | test "should cope with strange column names" do 168 | transaction do 169 | record_1 = Journalist.create! name: "Lois Lane" 170 | record_2 = Journalist.create! name: "Lois Lane" 171 | 172 | assert_equal "lois-lane", record_1.attributes["strange name"] 173 | assert_equal "lois-lane-2", record_2.attributes["strange name"] 174 | end 175 | end 176 | end 177 | 178 | class City < ActiveRecord::Base 179 | has_many :restaurants 180 | end 181 | 182 | class Restaurant < ActiveRecord::Base 183 | extend FriendlyId 184 | belongs_to :city 185 | friendly_id :name, use: [:sequentially_slugged, :scoped, :history], scope: :city 186 | end 187 | 188 | class SequentiallySluggedTestWithScopedHistory < TestCaseClass 189 | include FriendlyId::Test 190 | include FriendlyId::Test::Shared::Core 191 | 192 | def model_class 193 | Restaurant 194 | end 195 | 196 | test "should work with regeneration with scoped history" do 197 | transaction do 198 | city1 = City.create! 199 | City.create! 200 | record1 = model_class.create! name: "Test name", city: city1 201 | record2 = model_class.create! name: "Test name", city: city1 202 | 203 | assert_equal "test-name", record1.slug 204 | assert_equal "test-name-2", record2.slug 205 | 206 | record2.name = "Another test name" 207 | record2.slug = nil 208 | record2.save! 209 | 210 | record3 = model_class.create! name: "Test name", city: city1 211 | assert_equal "test-name-3", record3.slug 212 | end 213 | end 214 | end 215 | -------------------------------------------------------------------------------- /test/shared.rb: -------------------------------------------------------------------------------- 1 | module FriendlyId 2 | module Test 3 | module Shared 4 | module Slugged 5 | test "configuration should have a sequence_separator" do 6 | assert !model_class.friendly_id_config.sequence_separator.empty? 7 | end 8 | 9 | test "should make a new slug if the slug has been set to nil changed" do 10 | with_instance_of model_class do |record| 11 | record.name = "Changed Value" 12 | record.slug = nil 13 | record.save! 14 | assert_equal "changed-value", record.slug 15 | end 16 | end 17 | 18 | test "should add a UUID for duplicate friendly ids" do 19 | with_instance_of model_class do |record| 20 | record2 = model_class.create! name: record.name 21 | assert record2.friendly_id.match(/([0-9a-z]+-){4}[0-9a-z]+\z/) 22 | end 23 | end 24 | 25 | test "should not add slug sequence on update after other conflicting slugs were added" do 26 | with_instance_of model_class do |record| 27 | old = record.friendly_id 28 | model_class.create! name: record.name 29 | record.save! 30 | record.reload 31 | assert_equal old, record.to_param 32 | end 33 | end 34 | 35 | test "should not change the sequence on save" do 36 | with_instance_of model_class do |record| 37 | record2 = model_class.create! name: record.name 38 | friendly_id = record2.friendly_id 39 | record2.active = !record2.active 40 | record2.save! 41 | assert_equal friendly_id, record2.reload.friendly_id 42 | end 43 | end 44 | 45 | test "should create slug on save if the slug is nil" do 46 | with_instance_of model_class do |record| 47 | record.slug = nil 48 | record.save! 49 | refute_nil record.slug 50 | end 51 | end 52 | 53 | test "should set the slug to nil on dup" do 54 | with_instance_of model_class do |record| 55 | record2 = record.dup 56 | assert_nil record2.slug 57 | end 58 | end 59 | 60 | test "when validations block save, to_param should return friendly_id rather than nil" do 61 | my_model_class = Class.new(model_class) 62 | self.class.const_set("Foo", my_model_class) 63 | with_instance_of my_model_class do |record| 64 | record.update my_model_class.friendly_id_config.slug_column => nil 65 | record = my_model_class.friendly.find(record.id) 66 | record.class.validate proc { errors.add(:name, "FAIL") } 67 | record.save 68 | assert_equal record.to_param, record.friendly_id 69 | end 70 | end 71 | end 72 | 73 | module Core 74 | test "finds should respect conditions" do 75 | with_instance_of(model_class) do |record| 76 | assert_raises(ActiveRecord::RecordNotFound) do 77 | model_class.where("1 = 2").friendly.find record.friendly_id 78 | end 79 | assert_raises(ActiveRecord::RecordNotFound) do 80 | model_class.where("1 = 2").friendly.find record.id 81 | end 82 | end 83 | end 84 | 85 | test "should be findable by friendly id" do 86 | with_instance_of(model_class) { |record| assert model_class.friendly.find record.friendly_id } 87 | end 88 | 89 | test "should exist? by friendly id" do 90 | with_instance_of(model_class) do |record| 91 | assert model_class.friendly.exists? record.id 92 | assert model_class.friendly.exists? record.id.to_s 93 | assert model_class.friendly.exists? record.friendly_id 94 | assert model_class.friendly.exists?({id: record.id}) 95 | assert model_class.friendly.exists?(["id = ?", record.id]) 96 | assert !model_class.friendly.exists?(record.friendly_id + "-hello") 97 | assert !model_class.friendly.exists?(0) 98 | end 99 | end 100 | 101 | test "should be findable by id as integer" do 102 | with_instance_of(model_class) { |record| assert model_class.friendly.find record.id.to_i } 103 | end 104 | 105 | test "should be findable by id as string" do 106 | with_instance_of(model_class) { |record| assert model_class.friendly.find record.id.to_s } 107 | end 108 | 109 | test "should treat numeric part of string as an integer id" do 110 | with_instance_of(model_class) do |record| 111 | assert_raises(ActiveRecord::RecordNotFound) do 112 | model_class.friendly.find "#{record.id}-foo" 113 | end 114 | end 115 | end 116 | 117 | test "should be findable by numeric friendly_id" do 118 | with_instance_of(model_class, name: "206") { |record| assert model_class.friendly.find record.friendly_id } 119 | end 120 | 121 | test "to_param should return the friendly_id" do 122 | with_instance_of(model_class) { |record| assert_equal record.friendly_id, record.to_param } 123 | end 124 | 125 | if ActiveRecord::VERSION::MAJOR == 4 && ActiveRecord::VERSION::MINOR < 2 126 | test "should be findable by themselves" do 127 | with_instance_of(model_class) { |record| assert_equal record, model_class.friendly.find(record) } 128 | end 129 | end 130 | 131 | test "updating record's other values should not change the friendly_id" do 132 | with_instance_of model_class do |record| 133 | old = record.friendly_id 134 | record.update! active: false 135 | assert model_class.friendly.find old 136 | end 137 | end 138 | 139 | test "instances found by a single id should not be read-only" do 140 | with_instance_of(model_class) { |record| assert !model_class.friendly.find(record.friendly_id).readonly? } 141 | end 142 | 143 | test "failing finds with unfriendly_id should raise errors normally" do 144 | assert_raises(ActiveRecord::RecordNotFound) { model_class.friendly.find 0 } 145 | end 146 | 147 | test "should return numeric id if the friendly_id is nil" do 148 | with_instance_of(model_class) do |record| 149 | record.expects(:friendly_id).returns(nil) 150 | assert_equal record.id.to_s, record.to_param 151 | end 152 | end 153 | 154 | test "should return numeric id if the friendly_id is an empty string" do 155 | with_instance_of(model_class) do |record| 156 | record.expects(:friendly_id).returns("") 157 | assert_equal record.id.to_s, record.to_param 158 | end 159 | end 160 | 161 | test "should return the friendly_id as a string" do 162 | with_instance_of(model_class) do |record| 163 | record.expects(:friendly_id).returns(5) 164 | assert_equal "5", record.to_param 165 | end 166 | end 167 | 168 | test "should return numeric id if the friendly_id is blank" do 169 | with_instance_of(model_class) do |record| 170 | record.expects(:friendly_id).returns(" ") 171 | assert_equal record.id.to_s, record.to_param 172 | end 173 | end 174 | 175 | test "should return nil for to_param with a new record" do 176 | assert_nil model_class.new.to_param 177 | end 178 | end 179 | end 180 | end 181 | end 182 | -------------------------------------------------------------------------------- /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.slug_fr_ca = "jean-dupont" 19 | journalist.valid? 20 | I18n.with_locale(I18n.default_locale) do 21 | assert_equal "john-doe", journalist.friendly_id 22 | end 23 | I18n.with_locale(:es) do 24 | assert_equal "juan-fulano", journalist.friendly_id 25 | end 26 | I18n.with_locale(:"fr-CA") do 27 | assert_equal "jean-dupont", journalist.friendly_id 28 | end 29 | end 30 | 31 | test "should create record with slug in column for the current locale" do 32 | I18n.with_locale(I18n.default_locale) do 33 | journalist = Journalist.new(name: "John Doe") 34 | journalist.valid? 35 | assert_equal "john-doe", journalist.slug_en 36 | assert_nil journalist.slug_es 37 | end 38 | I18n.with_locale(:es) do 39 | journalist = Journalist.new(name: "John Doe") 40 | journalist.valid? 41 | assert_equal "john-doe", journalist.slug_es 42 | assert_nil journalist.slug_en 43 | end 44 | end 45 | 46 | test "to_param should return the numeric id when there's no slug for the current locale" do 47 | transaction do 48 | journalist = Journalist.new(name: "Juan Fulano") 49 | I18n.with_locale(:es) do 50 | journalist.save! 51 | assert_equal "juan-fulano", journalist.to_param 52 | end 53 | assert_equal journalist.id.to_s, journalist.to_param 54 | end 55 | end 56 | 57 | test "should set friendly id for locale" do 58 | transaction do 59 | journalist = Journalist.create!(name: "John Smith") 60 | journalist.set_friendly_id("Juan Fulano", :es) 61 | journalist.save! 62 | assert_equal "juan-fulano", journalist.slug_es 63 | I18n.with_locale(:es) do 64 | assert_equal "juan-fulano", journalist.to_param 65 | end 66 | end 67 | end 68 | 69 | test "set friendly_id should fall back default locale when none is given" do 70 | transaction do 71 | journalist = I18n.with_locale(:es) do 72 | Journalist.create!(name: "Juan Fulano") 73 | end 74 | journalist.set_friendly_id("John Doe") 75 | journalist.save! 76 | assert_equal "john-doe", journalist.slug_en 77 | end 78 | end 79 | 80 | test "should sequence localized slugs" do 81 | transaction do 82 | journalist = Journalist.create!(name: "John Smith") 83 | I18n.with_locale(:es) do 84 | Journalist.create!(name: "Juan Fulano") 85 | end 86 | journalist.set_friendly_id("Juan Fulano", :es) 87 | journalist.save! 88 | assert_equal "john-smith", journalist.to_param 89 | I18n.with_locale(:es) do 90 | assert_match(/juan-fulano-.+/, journalist.to_param) 91 | end 92 | end 93 | end 94 | 95 | class RegressionTest < TestCaseClass 96 | include FriendlyId::Test 97 | 98 | test "should not overwrite other locale's slugs on update" do 99 | transaction do 100 | journalist = Journalist.create!(name: "John Smith") 101 | journalist.set_friendly_id("Juan Fulano", :es) 102 | journalist.save! 103 | assert_equal "john-smith", journalist.to_param 104 | journalist.slug = nil 105 | journalist.update name: "Johnny Smith" 106 | assert_equal "johnny-smith", journalist.to_param 107 | I18n.with_locale(:es) do 108 | assert_equal "juan-fulano", journalist.to_param 109 | end 110 | end 111 | end 112 | end 113 | 114 | class ConfigurationTest < TestCaseClass 115 | test "should add locale to slug column for a non-default locale" do 116 | I18n.with_locale :es do 117 | assert_equal "slug_es", Journalist.friendly_id_config.slug_column 118 | end 119 | end 120 | 121 | test "should add locale to slug column for a locale with a region subtag" do 122 | I18n.with_locale :"fr-CA" do 123 | assert_equal "slug_fr_ca", Journalist.friendly_id_config.slug_column 124 | end 125 | end 126 | 127 | test "should add locale to non-default slug column and non-default locale" do 128 | model_class = Class.new(ActiveRecord::Base) do 129 | self.abstract_class = true 130 | extend FriendlyId 131 | friendly_id :name, use: :simple_i18n, slug_column: :foo 132 | end 133 | I18n.with_locale :es do 134 | assert_equal "foo_es", model_class.friendly_id_config.slug_column 135 | end 136 | end 137 | 138 | test "should add locale to slug column for default locale" do 139 | I18n.with_locale(I18n.default_locale) do 140 | assert_equal "slug_en", Journalist.friendly_id_config.slug_column 141 | end 142 | end 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /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.tr("-", "_") 19 | end 20 | end 21 | 22 | class SluggedTest < TestCaseClass 23 | include FriendlyId::Test 24 | include FriendlyId::Test::Shared::Core 25 | include FriendlyId::Test::Shared::Slugged 26 | 27 | def model_class 28 | Journalist 29 | end 30 | 31 | test "should allow validations on the slug" do 32 | model_class = Class.new(ActiveRecord::Base) do 33 | self.table_name = "articles" 34 | extend FriendlyId 35 | friendly_id :name, use: :slugged 36 | validates_length_of :slug, maximum: 1 37 | def self.name 38 | "Article" 39 | end 40 | end 41 | instance = model_class.new name: "hello" 42 | refute instance.valid? 43 | end 44 | 45 | test "should allow nil slugs" do 46 | transaction do 47 | m1 = model_class.create! 48 | model_class.create! 49 | assert_nil m1.slug 50 | end 51 | end 52 | 53 | test "should not break validates_uniqueness_of" do 54 | model_class = Class.new(ActiveRecord::Base) do 55 | self.table_name = "journalists" 56 | extend FriendlyId 57 | friendly_id :name, use: :slugged 58 | validates_uniqueness_of :slug_en 59 | def self.name 60 | "Journalist" 61 | end 62 | end 63 | transaction do 64 | instance = model_class.create! name: "hello", slug_en: "hello" 65 | instance2 = model_class.create name: "hello", slug_en: "hello" 66 | assert instance.valid? 67 | refute instance2.valid? 68 | end 69 | end 70 | 71 | test "should allow a record to reuse its own slug" do 72 | with_instance_of(model_class) do |record| 73 | old_id = record.friendly_id 74 | record.slug = nil 75 | record.save! 76 | assert_equal old_id, record.friendly_id 77 | end 78 | end 79 | 80 | test "should not update matching slug" do 81 | klass = Class.new model_class do 82 | def should_generate_new_friendly_id? 83 | name_changed? 84 | end 85 | end 86 | with_instance_of klass do |record| 87 | old_id = record.friendly_id 88 | record.name += " " 89 | record.save! 90 | assert_equal old_id, record.friendly_id 91 | end 92 | end 93 | 94 | test "should set slug on create if unrelated validations fail" do 95 | klass = Class.new model_class do 96 | validates_presence_of :active 97 | friendly_id :name, use: :slugged 98 | 99 | def self.name 100 | "Journalist" 101 | end 102 | end 103 | 104 | transaction do 105 | instance = klass.new name: "foo" 106 | refute instance.save 107 | refute instance.valid? 108 | assert_equal "foo", instance.slug 109 | end 110 | end 111 | 112 | test "should not set slug on create if slug validation fails" do 113 | klass = Class.new model_class do 114 | validates_presence_of :active 115 | validates_length_of :slug, minimum: 2 116 | friendly_id :name, use: :slugged 117 | 118 | def self.name 119 | "Journalist" 120 | end 121 | end 122 | 123 | transaction do 124 | instance = klass.new name: "x" 125 | refute instance.save 126 | refute instance.valid? 127 | assert_nil instance.slug 128 | end 129 | end 130 | 131 | test "should set slug on create if unrelated validations fail with custom slug_column" do 132 | klass = Class.new(ActiveRecord::Base) do 133 | self.table_name = "authors" 134 | extend FriendlyId 135 | validates_presence_of :active 136 | friendly_id :name, use: :slugged, slug_column: :subdomain 137 | 138 | def self.name 139 | "Author" 140 | end 141 | end 142 | 143 | transaction do 144 | instance = klass.new name: "foo" 145 | refute instance.save 146 | refute instance.valid? 147 | assert_equal "foo", instance.subdomain 148 | end 149 | end 150 | 151 | test "should not set slug on create if custom slug column validations fail" do 152 | klass = Class.new(ActiveRecord::Base) do 153 | self.table_name = "authors" 154 | extend FriendlyId 155 | validates_length_of :subdomain, minimum: 2 156 | friendly_id :name, use: :slugged, slug_column: :subdomain 157 | 158 | def self.name 159 | "Author" 160 | end 161 | end 162 | 163 | transaction do 164 | instance = klass.new name: "x" 165 | refute instance.save 166 | refute instance.valid? 167 | assert_nil instance.subdomain 168 | end 169 | end 170 | 171 | test "should keep new slug on save if unrelated validations fail" do 172 | klass = Class.new model_class do 173 | validates_presence_of :active 174 | friendly_id :name, use: :slugged 175 | 176 | def self.name 177 | "Journalist" 178 | end 179 | end 180 | 181 | transaction do 182 | instance = klass.new name: "foo", active: true 183 | assert instance.save 184 | assert instance.valid? 185 | instance.name = "foobar" 186 | instance.slug = nil 187 | instance.active = nil 188 | refute instance.save 189 | refute instance.valid? 190 | assert_equal "foobar", instance.slug 191 | end 192 | end 193 | 194 | test "should not update slug on save if slug validations fail" do 195 | klass = Class.new model_class do 196 | validates_length_of :slug, minimum: 2 197 | friendly_id :name, use: :slugged 198 | 199 | def self.name 200 | "Journalist" 201 | end 202 | end 203 | 204 | transaction do 205 | instance = klass.new name: "foo", active: true 206 | assert instance.save 207 | assert instance.valid? 208 | instance.name = "x" 209 | instance.slug = nil 210 | instance.active = nil 211 | refute instance.save 212 | assert_equal "foo", instance.slug 213 | end 214 | end 215 | end 216 | 217 | class SlugGeneratorTest < TestCaseClass 218 | include FriendlyId::Test 219 | 220 | def model_class 221 | Journalist 222 | end 223 | 224 | test "should quote column names" do 225 | model_class = Class.new(ActiveRecord::Base) do 226 | # This has been added in 635731bb to fix MySQL/Rubinius. It may still 227 | # be necessary, but causes an exception to be raised on Rails 4, so I'm 228 | # commenting it out. If it causes MySQL/Rubinius to fail again we'll 229 | # look for another solution. 230 | # self.abstract_class = true 231 | self.table_name = "journalists" 232 | extend FriendlyId 233 | friendly_id :name, use: :slugged, slug_column: "strange name" 234 | end 235 | 236 | begin 237 | with_instance_of(model_class) { |record| assert model_class.friendly.find(record.friendly_id) } 238 | rescue ActiveRecord::StatementInvalid 239 | flunk "column name was not quoted" 240 | end 241 | end 242 | 243 | test "should not resequence lower sequences on update" do 244 | transaction do 245 | m1 = model_class.create! name: "a b c d" 246 | assert_equal "a-b-c-d", m1.slug 247 | model_class.create! name: "a b c d" 248 | m1 = model_class.friendly.find(m1.id) 249 | m1.save! 250 | assert_equal "a-b-c-d", m1.slug 251 | end 252 | end 253 | 254 | test "should correctly sequence slugs that end with numbers" do 255 | transaction do 256 | record1 = model_class.create! name: "Peugeot 206" 257 | assert_equal "peugeot-206", record1.slug 258 | record2 = model_class.create! name: "Peugeot 206" 259 | assert_match(/\Apeugeot-206-([a-z0-9]+-){4}[a-z0-9]+\z/, record2.slug) 260 | end 261 | end 262 | 263 | test "should correctly sequence slugs with underscores" do 264 | transaction do 265 | Novelist.create! name: "wordsfail, buildings tumble" 266 | record2 = Novelist.create! name: "word fail" 267 | assert_equal "word_fail", record2.slug 268 | end 269 | end 270 | 271 | test "should correctly sequence numeric slugs" do 272 | transaction do 273 | n2 = 2.times.map { Article.create name: "123" }.last 274 | assert_match(/\A123-.*/, n2.friendly_id) 275 | end 276 | end 277 | end 278 | 279 | class SlugSeparatorTest < TestCaseClass 280 | include FriendlyId::Test 281 | 282 | class Journalist < ActiveRecord::Base 283 | extend FriendlyId 284 | friendly_id :name, use: :slugged, sequence_separator: ":" 285 | end 286 | 287 | def model_class 288 | Journalist 289 | end 290 | 291 | test "should sequence with configured sequence separator" do 292 | with_instance_of model_class do |record| 293 | record2 = model_class.create! name: record.name 294 | assert record2.friendly_id.match(/:.*\z/) 295 | end 296 | end 297 | 298 | test "should detect when a stored slug has been cleared" do 299 | with_instance_of model_class do |record| 300 | record.slug = nil 301 | assert record.should_generate_new_friendly_id? 302 | end 303 | end 304 | 305 | test "should correctly sequence slugs that uses single dashes as sequence separator" do 306 | model_class = Class.new(ActiveRecord::Base) do 307 | self.table_name = "journalists" 308 | extend FriendlyId 309 | friendly_id :name, use: :slugged, sequence_separator: "-" 310 | def self.name 311 | "Journalist" 312 | end 313 | end 314 | transaction do 315 | record1 = model_class.create! name: "Peugeot 206" 316 | assert_equal "peugeot-206", record1.slug 317 | record2 = model_class.create! name: "Peugeot 206" 318 | assert_match(/\Apeugeot-206-([a-z0-9]+-){4}[a-z0-9]+\z/, record2.slug) 319 | end 320 | end 321 | 322 | test "should sequence blank slugs without a separator" do 323 | with_instance_of model_class, name: "" do |record| 324 | assert_match(/\A([a-z0-9]+-){4}[a-z0-9]+\z/, record.slug) 325 | end 326 | end 327 | end 328 | 329 | class SlugLimitTest < TestCaseClass 330 | include FriendlyId::Test 331 | 332 | class Journalist < ActiveRecord::Base 333 | extend FriendlyId 334 | friendly_id :name, use: :slugged, slug_limit: 40 335 | end 336 | 337 | def model_class 338 | Journalist 339 | end 340 | 341 | test "should limit slug size" do 342 | transaction do 343 | m1 = model_class.create! name: "a" * 50 344 | assert_equal m1.slug, "a" * 40 345 | m2 = model_class.create! name: m1.name 346 | m2.save! 347 | # "aaa-" 348 | assert_match(/\Aa{3}-/, m2.slug) 349 | end 350 | end 351 | end 352 | 353 | class DefaultScopeTest < TestCaseClass 354 | include FriendlyId::Test 355 | 356 | class Journalist < ActiveRecord::Base 357 | extend FriendlyId 358 | friendly_id :name, use: :slugged 359 | default_scope -> { where(active: true).order("id ASC") } 360 | end 361 | 362 | test "friendly_id should correctly sequence a default_scoped ordered table" do 363 | transaction do 364 | 3.times { assert Journalist.create name: "a", active: true } 365 | end 366 | end 367 | 368 | test "friendly_id should correctly sequence a default_scoped scoped table" do 369 | transaction do 370 | assert Journalist.create name: "a", active: false 371 | assert Journalist.create name: "a", active: true 372 | end 373 | end 374 | end 375 | 376 | class UuidAsPrimaryKeyFindTest < TestCaseClass 377 | include FriendlyId::Test 378 | 379 | class MenuItem < ActiveRecord::Base 380 | extend FriendlyId 381 | friendly_id :name, use: :slugged 382 | before_create :init_primary_key 383 | 384 | def self.primary_key 385 | "uuid_key" 386 | end 387 | 388 | # Overwrite the method added by FriendlyId 389 | def self.primary_key_type 390 | :uuid 391 | end 392 | 393 | private 394 | 395 | def init_primary_key 396 | self.uuid_key = SecureRandom.uuid 397 | end 398 | end 399 | 400 | def model_class 401 | MenuItem 402 | end 403 | 404 | test "should have a uuid_key as a primary key" do 405 | assert_equal "uuid_key", model_class.primary_key 406 | assert_equal :uuid, model_class.primary_key_type 407 | end 408 | 409 | test "should be findable by the UUID primary key" do 410 | with_instance_of(model_class) do |record| 411 | assert model_class.friendly.find record.id 412 | end 413 | end 414 | 415 | test "should handle a string that simply contains a UUID correctly" do 416 | with_instance_of(model_class) do |record| 417 | assert_raises(ActiveRecord::RecordNotFound) do 418 | model_class.friendly.find "test-#{SecureRandom.uuid}" 419 | end 420 | end 421 | end 422 | end 423 | 424 | class UnderscoreAsSequenceSeparatorRegressionTest < TestCaseClass 425 | include FriendlyId::Test 426 | 427 | class Manual < ActiveRecord::Base 428 | extend FriendlyId 429 | friendly_id :name, use: :slugged, sequence_separator: "_" 430 | end 431 | 432 | test "should not create duplicate slugs" do 433 | 3.times do 434 | transaction do 435 | assert Manual.create! name: "foo" 436 | rescue 437 | flunk "Tried to insert duplicate slug" 438 | end 439 | end 440 | end 441 | end 442 | 443 | # https://github.com/norman/friendly_id/issues/148 444 | class FailedValidationAfterUpdateRegressionTest < TestCaseClass 445 | include FriendlyId::Test 446 | 447 | class Journalist < ActiveRecord::Base 448 | extend FriendlyId 449 | friendly_id :name, use: :slugged 450 | validates_presence_of :slug_de 451 | end 452 | 453 | test "to_param should return the unchanged value if the slug changes before validation fails" do 454 | transaction do 455 | journalist = Journalist.create! name: "Joseph Pulitzer", slug_de: "value" 456 | assert_equal "joseph-pulitzer", journalist.to_param 457 | assert journalist.valid? 458 | assert journalist.persisted? 459 | journalist.name = "Joe Pulitzer" 460 | journalist.slug_de = nil 461 | assert !journalist.valid? 462 | assert_equal "joseph-pulitzer", journalist.to_param 463 | end 464 | end 465 | end 466 | 467 | # https://github.com/norman/friendly_id/issues/947 468 | class GeneratingSlugWithValidationSkippedTest < TestCaseClass 469 | include FriendlyId::Test 470 | 471 | class Journalist < ActiveRecord::Base 472 | extend FriendlyId 473 | friendly_id :name, use: :slugged 474 | end 475 | 476 | test "should generate slug when skipping validation" do 477 | transaction do 478 | m1 = Journalist.new 479 | m1.name = "Bob Timesletter" 480 | m1.save(validate: false) 481 | assert_equal "bob-timesletter", m1.slug 482 | end 483 | end 484 | 485 | test "should generate slug when #valid? called" do 486 | transaction do 487 | m1 = Journalist.new 488 | m1.name = "Bob Timesletter" 489 | m1.valid? 490 | m1.save(validate: false) 491 | assert_equal "bob-timesletter", m1.slug 492 | end 493 | end 494 | end 495 | 496 | class ToParamTest < TestCaseClass 497 | include FriendlyId::Test 498 | 499 | class Journalist < ActiveRecord::Base 500 | extend FriendlyId 501 | validates_presence_of :active 502 | validates_length_of :slug, minimum: 2 503 | friendly_id :name, use: :slugged 504 | 505 | attr_accessor :to_param_in_callback 506 | 507 | after_save do 508 | self.to_param_in_callback = to_param 509 | end 510 | end 511 | 512 | test "to_param should return nil if record is unpersisted" do 513 | assert_nil Journalist.new.to_param 514 | end 515 | 516 | test "to_param should return original slug if record failed validation" do 517 | journalist = Journalist.new name: "Clark Kent", active: nil 518 | refute journalist.save 519 | assert_equal "clark-kent", journalist.to_param 520 | end 521 | 522 | test "to_param should clear slug attributes if slug attribute fails validation" do 523 | journalist = Journalist.new name: "x", active: true 524 | refute journalist.save 525 | assert_nil journalist.to_param 526 | end 527 | 528 | test "to_param should clear slug attribute if slug attribute fails validation and unrelated validation fails" do 529 | journalist = Journalist.new name: "x", active: nil 530 | refute journalist.save 531 | assert_nil journalist.to_param 532 | end 533 | 534 | test "to_param should use slugged attribute if record saved successfully" do 535 | transaction do 536 | journalist = Journalist.new name: "Clark Kent", active: true 537 | assert journalist.save 538 | assert_equal "clark-kent", journalist.to_param 539 | end 540 | end 541 | 542 | test "to_param should use new slug if existing record changes but fails to save" do 543 | transaction do 544 | journalist = Journalist.new name: "Clark Kent", active: true 545 | assert journalist.save 546 | journalist.name = "Superman" 547 | journalist.slug = nil 548 | journalist.active = nil 549 | refute journalist.save 550 | assert_equal "superman", journalist.to_param 551 | end 552 | end 553 | 554 | test "to_param should use original slug if new slug attribute is not valid" do 555 | transaction do 556 | journalist = Journalist.new name: "Clark Kent", active: true 557 | assert journalist.save 558 | journalist.name = "x" 559 | journalist.slug = nil 560 | journalist.active = nil 561 | refute journalist.save 562 | assert_equal "clark-kent", journalist.to_param 563 | end 564 | end 565 | 566 | test "to_param should use new slug if existing record changes successfully" do 567 | transaction do 568 | journalist = Journalist.new name: "Clark Kent", active: true 569 | assert journalist.save 570 | journalist.name = "Superman" 571 | journalist.slug = nil 572 | assert journalist.save 573 | assert_equal "superman", journalist.to_param 574 | end 575 | end 576 | 577 | test "to_param should use new slug within callbacks if new record is saved successfully" do 578 | transaction do 579 | journalist = Journalist.new name: "Clark Kent", active: true 580 | assert journalist.save 581 | assert_equal "clark-kent", journalist.to_param_in_callback, "value of to_param in callback should use the new slug value" 582 | end 583 | end 584 | 585 | test "to_param should use new slug within callbacks if existing record changes successfully" do 586 | transaction do 587 | journalist = Journalist.new name: "Clark Kent", active: true 588 | assert journalist.save 589 | assert journalist.valid? 590 | journalist.name = "Superman" 591 | journalist.slug = nil 592 | assert journalist.save, "save should be successful" 593 | assert_equal "superman", journalist.to_param_in_callback, "value of to_param in callback should use the new slug value" 594 | end 595 | end 596 | end 597 | 598 | class ConfigurableRoutesTest < TestCaseClass 599 | include FriendlyId::Test 600 | 601 | class Article < ActiveRecord::Base 602 | extend FriendlyId 603 | 604 | friendly_id :name, use: :slugged, routes: :friendly 605 | end 606 | 607 | class Novel < ActiveRecord::Base 608 | extend FriendlyId 609 | 610 | friendly_id :name, use: :slugged, routes: :default 611 | end 612 | 613 | test "to_param should return a friendly id when the routes option is set to :friendly" do 614 | transaction do 615 | article = Article.create! name: "Titanic Hits; Iceberg Sinks" 616 | 617 | assert_equal "titanic-hits-iceberg-sinks", article.to_param 618 | end 619 | end 620 | 621 | test "to_param should return the id when the routes option is set to anything but friendly" do 622 | transaction do 623 | novel = Novel.create! name: "Don Quixote" 624 | 625 | assert_equal novel.id.to_s, novel.to_param 626 | end 627 | end 628 | end 629 | -------------------------------------------------------------------------------- /test/sti_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | class StiTest < TestCaseClass 4 | include FriendlyId::Test 5 | include FriendlyId::Test::Shared::Core 6 | include FriendlyId::Test::Shared::Slugged 7 | 8 | class Journalist < ActiveRecord::Base 9 | extend FriendlyId 10 | friendly_id :name, use: [:slugged] 11 | end 12 | 13 | class Editorialist < Journalist 14 | end 15 | 16 | def model_class 17 | Editorialist 18 | end 19 | 20 | test "friendly_id should accept a base and a hash with single table inheritance" do 21 | abstract_klass = Class.new(ActiveRecord::Base) do 22 | def self.table_exists? 23 | false 24 | end 25 | extend FriendlyId 26 | friendly_id :foo, use: :slugged, slug_column: :bar 27 | end 28 | klass = Class.new(abstract_klass) 29 | assert klass < FriendlyId::Slugged 30 | assert_equal :foo, klass.friendly_id_config.base 31 | assert_equal :bar, klass.friendly_id_config.slug_column 32 | end 33 | 34 | test "the configuration's model_class should be the class, not the base_class" do 35 | assert_equal model_class, model_class.friendly_id_config.model_class 36 | end 37 | 38 | test "friendly_id should accept a block with single table inheritance" do 39 | abstract_klass = Class.new(ActiveRecord::Base) do 40 | def self.table_exists? 41 | false 42 | end 43 | extend FriendlyId 44 | friendly_id :foo do |config| 45 | config.use :slugged 46 | config.base = :foo 47 | config.slug_column = :bar 48 | end 49 | end 50 | klass = Class.new(abstract_klass) 51 | assert klass < FriendlyId::Slugged 52 | assert_equal :foo, klass.friendly_id_config.base 53 | assert_equal :bar, klass.friendly_id_config.slug_column 54 | end 55 | 56 | test "friendly_id slugs should not clash with each other" do 57 | transaction do 58 | journalist = model_class.base_class.create! name: "foo bar" 59 | editoralist = model_class.create! name: "foo bar" 60 | 61 | assert_equal "foo-bar", journalist.slug 62 | assert_match(/foo-bar-.+/, editoralist.slug) 63 | end 64 | end 65 | end 66 | 67 | class StiTestWithHistory < StiTest 68 | class Journalist < ActiveRecord::Base 69 | extend FriendlyId 70 | friendly_id :name, use: [:slugged, :history] 71 | end 72 | 73 | class Editorialist < Journalist 74 | end 75 | 76 | def model_class 77 | Editorialist 78 | end 79 | end 80 | 81 | class StiTestWithFinders < TestCaseClass 82 | include FriendlyId::Test 83 | 84 | class Journalist < ActiveRecord::Base 85 | extend FriendlyId 86 | friendly_id :name, use: [:slugged, :finders] 87 | end 88 | 89 | class Editorialist < Journalist 90 | extend FriendlyId 91 | friendly_id :name, use: [:slugged, :finders] 92 | end 93 | 94 | def model_class 95 | Editorialist 96 | end 97 | 98 | test "friendly_id slugs should be looked up from subclass with friendly" do 99 | transaction do 100 | editoralist = model_class.create! name: "foo bar" 101 | assert_equal editoralist, model_class.friendly.find(editoralist.slug) 102 | end 103 | end 104 | 105 | test "friendly_id slugs should be looked up from subclass" do 106 | transaction do 107 | editoralist = model_class.create! name: "foo bar" 108 | assert_equal editoralist, model_class.find(editoralist.slug) 109 | end 110 | end 111 | end 112 | 113 | class StiTestSubClass < TestCaseClass 114 | include FriendlyId::Test 115 | 116 | class Journalist < ActiveRecord::Base 117 | extend FriendlyId 118 | end 119 | 120 | class Editorialist < Journalist 121 | extend FriendlyId 122 | friendly_id :name, use: [:slugged, :finders] 123 | end 124 | 125 | def model_class 126 | Editorialist 127 | end 128 | 129 | test "friendly_id slugs can be created and looked up from subclass" do 130 | transaction do 131 | editoralist = model_class.create! name: "foo bar" 132 | assert_equal editoralist, model_class.find(editoralist.slug) 133 | end 134 | end 135 | end 136 | --------------------------------------------------------------------------------