Rails4 Friendly_id Unique Slug Formatting

前端 未结 4 1956
抹茶落季
抹茶落季 2021-02-15 22:15

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.



        
相关标签:
4条回答
  • 2021-02-15 23:06

    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
    
    0 讨论(0)
  • 2021-02-15 23:09

    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).

    0 讨论(0)
  • 2021-02-15 23:11

    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.

    0 讨论(0)
  • 2021-02-15 23:13

    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.

    0 讨论(0)
提交回复
热议问题