Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Rails4 Friendly_id Unique Slug Formatting

I am using friendly_id gem for slugging my models. Since the slug has to be unique when i enter the same data to check i get a long hashed appending in the slug.

Explore     explore 
Explore     explore-7a8411ac-5af5-41a3-ab08-d32387679f2b

Is there a way to tell friendly_id to give better formatted slugs like explore-1 and explore-2

Version: friendly_id 5.0.4

like image 418
Harsha M V Avatar asked Jun 19 '14 12:06

Harsha M V


4 Answers

Agreed, it seems like pretty rough behavior.

If you look at code of friendly_id/slugged.rb, there are 2 functions handing conflicts resolution logic:

def resolve_friendly_id_conflict(candidates)
  candidates.first + friendly_id_config.sequence_separator + SecureRandom.uuid
end

# Sets the slug.
def set_slug(normalized_slug = nil)
  if should_generate_new_friendly_id?
    candidates = FriendlyId::Candidates.new(self, normalized_slug || send(friendly_id_config.base))
    slug = slug_generator.generate(candidates) || resolve_friendly_id_conflict(candidates)
    send "#{friendly_id_config.slug_column}=", slug
  end
end

So, the idea is just to monkey patch it. I see 2 options:

  1. Just patch resolve_friendly_id_conflict, add your random suffix.

  2. Change logic of both methods with intention of trying all candidates until slug_generator.generate(candidates) returns something not empty. If all candidates give nil then fallback to resolve_friendly_id_conflict method. Using this technique you can use slug candidates to append model's id when slug is not unique.

Ideally, it would be nice if gem's authors added a config option to handle unique slugs resolution (method symbol or proc taking generator and candidates as params) or just check if model responds to some method.

Besides, in some use cases unique slugs resolution in not needed at all. For example, if we just want to rely on validates_uniqueness_of :slug or uniqueness validation of candidates.

like image 69
x3mka Avatar answered Sep 28 '22 01:09

x3mka


So if anyone comes across this at some point, I have an update I would have preferred to put as a comment in tirdadc's comment, but I can't (not enough reputation). So, here you go:

Tirdadc's answer is perfect, in theory, but unfortunately the id of an object isn't yet assigned at the point slug_candidates is called, so you need to do a little trickery. Here's the full way to get a slug with the id of the object in there:

class YourModel < ActiveRecord::Base
  extend FriendlyId
  friendly_id :slug_candidates, use: :slugged
  after_create :remake_slug

  # Try building a slug based on the following fields in
  # increasing order of specificity.
  def slug_candidates
    [
      :name,
      [:name, :id],
    ]
  end

  def remake_slug
    self.update_attribute(:slug, nil)
    self.save!
  end

  #You don't necessarily need this bit, but I have it in there anyways
  def should_generate_new_friendly_id?
    new_record? || self.slug.nil?
  end
end

So you're basically setting the slug after the creation of the object and then after the object is done being created, you nil out the slug and perform a save, which will reassign the slug (now with the id intact). Is saving an object in an after_create call dangerous? Probably, but it seems to work for me.

like image 33
user3062913 Avatar answered Sep 28 '22 01:09

user3062913


I would recommend using the :scoped module if you want to avoid UUIDs in your slugs when dealing with collisions. Here's the documentation along with an example:

http://norman.github.io/friendly_id/file.Guide.html#Unique_Slugs_by_Scope

Try using :scope => :id since each id will be unique anyway and see if that works for you.

UPDATE:

To get exactly what you want, you now have candidates for that purpose in version 5:

class YourModel < ActiveRecord::Base
  extend FriendlyId
  friendly_id :slug_candidates, use: :slugged

  # Try building a slug based on the following fields in
  # increasing order of specificity.
  def slug_candidates
    [
      :name,
      [:name, :id],
    ]
  end
end
like image 39
tirdadc Avatar answered Sep 28 '22 00:09

tirdadc


Today I came accross this issue and while other answer helped me get started, I wasn't satisfied because, like you, I wanted to have the slugs appear in sequence like explore, explore-2, explore-3.

So, here's how I fixed it:

class Thing < ActiveRecord::Base
  extend FriendlyId
  friendly_id :slug_candidates, use: :slugged

  validates :name, presence: true, uniqueness: { case_sensitive: false }
  validates :slug, uniqueness: true

  def slug_candidates
    [:name, [:name, :id_for_slug]]
  end

  def id_for_slug
    generated_slug = normalize_friendly_id(name)
    things = self.class.where('slug REGEXP :pattern', pattern: "#{generated_slug}(-[0-9]+)?$")
    things = things.where.not(id: id) unless new_record?
    things.count + 1
  end

  def should_generate_new_friendly_id?
    name_changed? || super
  end
end

I used uniqueness validation for :slug just in case this model is being used in concurrent code.

Here you can see it working:

irb(main):001:0> Thing.create(name: 'New thing')
   (0.1ms)  begin transaction
   (0.2ms)  SELECT COUNT(*) FROM "things" WHERE (slug REGEXP 'new-thing(-[0-9]+)?$')
  Thing Exists (0.1ms)  SELECT  1 AS one FROM "things" WHERE ("things"."id" IS NOT NULL) AND "things"."slug" = ? LIMIT 1  [["slug", "new-thing"]]
  Thing Exists (0.1ms)  SELECT  1 AS one FROM "things" WHERE LOWER("things"."name") = LOWER('New thing') LIMIT 1
  Thing Exists (0.1ms)  SELECT  1 AS one FROM "things" WHERE "things"."slug" = 'new-thing' LIMIT 1
  SQL (0.4ms)  INSERT INTO "things" ("name", "slug") VALUES (?, ?)  [["name", "New thing"], ["slug", "new-thing"]]
   (115.7ms)  commit transaction
=> #<Thing id: 1, name: "New thing", slug: "new-thing">
irb(main):002:0> Thing.create(name: 'New thing')
   (0.2ms)  begin transaction
   (0.9ms)  SELECT COUNT(*) FROM "things" WHERE (slug REGEXP 'new-thing(-[0-9]+)?$')
  Thing Exists (0.1ms)  SELECT  1 AS one FROM "things" WHERE ("things"."id" IS NOT NULL) AND "things"."slug" = ? LIMIT 1  [["slug", "new-thing"]]
  Thing Exists (0.1ms)  SELECT  1 AS one FROM "things" WHERE ("things"."id" IS NOT NULL) AND "things"."slug" = ? LIMIT 1  [["slug", "new-thing-2"]]
  Thing Exists (0.1ms)  SELECT  1 AS one FROM "things" WHERE LOWER("things"."name") = LOWER('New thing') LIMIT 1
  Thing Exists (0.1ms)  SELECT  1 AS one FROM "things" WHERE "things"."slug" = 'new-thing-2' LIMIT 1
   (0.1ms)  rollback transaction
=> #<Thing id: nil, name: "New thing", slug: "new-thing-2">
irb(main):003:0> Thing.create(name: 'New-thing')
   (0.2ms)  begin transaction
   (0.5ms)  SELECT COUNT(*) FROM "things" WHERE (slug REGEXP 'new-thing(-[0-9]+)?$')
  Thing Exists (0.1ms)  SELECT  1 AS one FROM "things" WHERE ("things"."id" IS NOT NULL) AND "things"."slug" = ? LIMIT 1  [["slug", "new-thing"]]
  Thing Exists (0.1ms)  SELECT  1 AS one FROM "things" WHERE ("things"."id" IS NOT NULL) AND "things"."slug" = ? LIMIT 1  [["slug", "new-thing-2"]]
  Thing Exists (0.3ms)  SELECT  1 AS one FROM "things" WHERE LOWER("things"."name") = LOWER('New-thing') LIMIT 1
  Thing Exists (0.3ms)  SELECT  1 AS one FROM "things" WHERE "things"."slug" = 'new-thing-2' LIMIT 1
  SQL (0.4ms)  INSERT INTO "things" ("name", "slug") VALUES (?, ?)  [["name", "New-thing"], ["slug", "new-thing-2"]]
   (108.9ms)  commit transaction
=> #<Thing id: 2, name: "New-thing", slug: "new-thing-2">
irb(main):004:0> Thing.create(name: 'New!thing')
   (0.2ms)  begin transaction
   (0.6ms)  SELECT COUNT(*) FROM "things" WHERE (slug REGEXP 'new-thing(-[0-9]+)?$')
  Thing Exists (0.0ms)  SELECT  1 AS one FROM "things" WHERE ("things"."id" IS NOT NULL) AND "things"."slug" = ? LIMIT 1  [["slug", "new-thing"]]
  Thing Exists (0.1ms)  SELECT  1 AS one FROM "things" WHERE ("things"."id" IS NOT NULL) AND "things"."slug" = ? LIMIT 1  [["slug", "new-thing-3"]]
  Thing Exists (0.1ms)  SELECT  1 AS one FROM "things" WHERE LOWER("things"."name") = LOWER('New!thing') LIMIT 1
  Thing Exists (0.1ms)  SELECT  1 AS one FROM "things" WHERE "things"."slug" = 'new-thing-3' LIMIT 1
  SQL (0.1ms)  INSERT INTO "things" ("name", "slug") VALUES (?, ?)  [["name", "New!thing"], ["slug", "new-thing-3"]]
   (112.4ms)  commit transaction
=> #<Thing id: 3, name: "New!thing", slug: "new-thing-3">
irb(main):005:0> 

Also, if you use sqlite3 adapter you'll need to install sqlite3_ar_regexp gem (it won't be very fast, because SQLite doesn't have REGEXP() and it evaluates Ruby code instead).

like image 39
Almir Sarajčić Avatar answered Sep 28 '22 01:09

Almir Sarajčić