norman/friendly_id

View on GitHub
lib/friendly_id/sequentially_slugged/calculator.rb

Summary

Maintainability
A
35 mins
Test Coverage
module FriendlyId
  module SequentiallySlugged
    class Calculator
      attr_accessor :scope, :slug, :slug_column, :sequence_separator

      def initialize(scope, slug, slug_column, sequence_separator, base_class)
        @scope = scope
        @slug = slug
        table_name = scope.connection.quote_table_name(base_class.arel_table.name)
        @slug_column = "#{table_name}.#{scope.connection.quote_column_name(slug_column)}"
        @sequence_separator = sequence_separator
      end

      def next_slug
        slug + sequence_separator + next_sequence_number.to_s
      end

      private

      def conflict_query
        base = "#{slug_column} = ? OR #{slug_column} LIKE ?"
        # Awful hack for SQLite3, which does not pick up '\' as the escape character
        # without this.
        base << " ESCAPE '\\'" if /sqlite/i.match?(scope.connection.adapter_name)
        base
      end

      def next_sequence_number
        last_sequence_number ? last_sequence_number + 1 : 2
      end

      def last_sequence_number
        # Reject slug_conflicts that doesn't come from the first_candidate
        # Map all sequence numbers and take the maximum
        slug_conflicts
          .reject { |slug_conflict| !regexp.match(slug_conflict) }
          .map { |slug_conflict| regexp.match(slug_conflict)[1].to_i }
          .max
      end

      # Return the unnumbered (shortest) slug first, followed by the numbered ones
      # in ascending order.
      def ordering_query
        "#{sql_length}(#{slug_column}) ASC, #{slug_column} ASC"
      end

      def regexp
        /#{slug}#{sequence_separator}(\d+)\z/
      end

      def sequential_slug_matcher
        # Underscores (matching a single character) and percent signs (matching
        # any number of characters) need to be escaped. While this looks like
        # an excessive number of backslashes, it is correct.
        "#{slug}#{sequence_separator}".gsub(/[_%]/, '\\\\\&') + "%"
      end

      def slug_conflicts
        scope
          .where(conflict_query, slug, sequential_slug_matcher)
          .order(Arel.sql(ordering_query)).pluck(Arel.sql(slug_column))
      end

      def sql_length
        /sqlserver/i.match?(scope.connection.adapter_name) ? "LEN" : "LENGTH"
      end
    end
  end
end