routable.rb 6.0 KB
Newer Older
1
# Store object full path in separate table for easy lookup and uniq validation
2
# Object must have name and path db fields and respond to parent and parent_changed? methods.
3 4 5 6 7 8 9
module Routable
  extend ActiveSupport::Concern

  included do
    has_one :route, as: :source, autosave: true, dependent: :destroy

    validates_associated :route
10
    validates :route, presence: true
11

12 13 14 15 16 17 18
    scope :with_route, -> { includes(:route) }

    before_validation do
      if full_path_changed? || full_name_changed?
        prepare_route
      end
    end
19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
  end

  class_methods do
    # Finds a single object by full path match in routes table.
    #
    # Usage:
    #
    #     Klass.find_by_full_path('gitlab-org/gitlab-ce')
    #
    # Returns a single object, or nil.
    def find_by_full_path(path)
      # On MySQL we want to ensure the ORDER BY uses a case-sensitive match so
      # any literal matches come first, for this we have to use "BINARY".
      # Without this there's still no guarantee in what order MySQL will return
      # rows.
      binary = Gitlab::Database.mysql? ? 'BINARY' : ''

      order_sql = "(CASE WHEN #{binary} routes.path = #{connection.quote(path)} THEN 0 ELSE 1 END)"

38
      where_full_path_in([path]).reorder(order_sql).take
39 40 41 42 43 44
    end

    # Builds a relation to find multiple objects by their full paths.
    #
    # Usage:
    #
45
    #     Klass.where_full_path_in(%w{gitlab-org/gitlab-ce gitlab-org/gitlab-ee})
46 47
    #
    # Returns an ActiveRecord::Relation.
48
    def where_full_path_in(paths)
49 50 51 52 53 54
      wheres = []
      cast_lower = Gitlab::Database.postgresql?

      paths.each do |path|
        path = connection.quote(path)

55 56 57 58 59 60
        where =
          if cast_lower
            "(LOWER(routes.path) = LOWER(#{path}))"
          else
            "(routes.path = #{path})"
          end
61 62 63 64 65 66 67 68 69 70

        wheres << where
      end

      if wheres.empty?
        none
      else
        joins(:route).where(wheres.join(' OR '))
      end
    end
71 72 73 74 75 76 77 78 79 80 81 82 83 84 85

    # Builds a relation to find multiple objects that are nested under user membership
    #
    # Usage:
    #
    #     Klass.member_descendants(1)
    #
    # Returns an ActiveRecord::Relation.
    def member_descendants(user_id)
      joins(:route).
        joins("INNER JOIN routes r2 ON routes.path LIKE CONCAT(r2.path, '/%')
               INNER JOIN members ON members.source_id = r2.source_id
               AND members.source_type = r2.source_type").
        where('members.user_id = ?', user_id)
    end
86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145

    # Builds a relation to find multiple objects that are nested under user
    # membership. Includes the parent, as opposed to `#member_descendants`
    # which only includes the descendants.
    #
    # Usage:
    #
    #     Klass.member_self_and_descendants(1)
    #
    # Returns an ActiveRecord::Relation.
    def member_self_and_descendants(user_id)
      joins(:route).
        joins("INNER JOIN routes r2 ON routes.path LIKE CONCAT(r2.path, '/%')
               OR routes.path = r2.path
               INNER JOIN members ON members.source_id = r2.source_id
               AND members.source_type = r2.source_type").
        where('members.user_id = ?', user_id)
    end

    # Returns all objects in a hierarchy, where any node in the hierarchy is
    # under the user membership.
    #
    # Usage:
    #
    #     Klass.member_hierarchy(1)
    #
    # Examples:
    #
    #     Given the following group tree...
    #
    #            _______group_1_______
    #           |                     |
    #           |                     |
    #     nested_group_1        nested_group_2
    #           |                     |
    #           |                     |
    #     nested_group_1_1      nested_group_2_1
    #
    #
    #     ... the following results are returned:
    #
    #     * the user is a member of group 1
    #       => 'group_1',
    #          'nested_group_1', nested_group_1_1',
    #          'nested_group_2', 'nested_group_2_1'
    #
    #     * the user is a member of nested_group_2
    #       => 'group1',
    #          'nested_group_2', 'nested_group_2_1'
    #
    #     * the user is a member of nested_group_2_1
    #       => 'group1',
    #          'nested_group_2', 'nested_group_2_1'
    #
    # Returns an ActiveRecord::Relation.
    def member_hierarchy(user_id)
      paths = member_self_and_descendants(user_id).pluck('routes.path')

      return none if paths.empty?

146
      wheres = paths.map do |path|
147 148 149
        "#{connection.quote(path)} = routes.path
         OR
         #{connection.quote(path)} LIKE CONCAT(routes.path, '/%')"
150 151 152 153
      end

      joins(:route).where(wheres.join(' OR '))
    end
154 155
  end

156 157 158 159 160 161 162 163 164 165
  def full_name
    if route && route.name.present?
      @full_name ||= route.name
    else
      update_route if persisted?

      build_full_name
    end
  end

166 167 168 169
  # Every time `project.namespace.becomes(Namespace)` is called for polymorphic_path,
  # a new instance is instantiated, and we end up duplicating the same query to retrieve
  # the route. Caching this per request ensures that even if we have multiple instances,
  # we will not have to duplicate work, avoiding N+1 queries in some cases.
170
  def full_path
171 172 173 174 175 176 177 178 179
    return uncached_full_path unless RequestStore.active?

    key = "routable/full_path/#{self.class.name}/#{self.id}"
    RequestStore[key] ||= uncached_full_path
  end

  private

  def uncached_full_path
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218
    if route && route.path.present?
      @full_path ||= route.path
    else
      update_route if persisted?

      build_full_path
    end
  end

  def full_name_changed?
    name_changed? || parent_changed?
  end

  def full_path_changed?
    path_changed? || parent_changed?
  end

  def build_full_name
    if parent && name
      parent.human_name + ' / ' + name
    else
      name
    end
  end

  def build_full_path
    if parent && path
      parent.full_path + '/' + path
    else
      path
    end
  end

  def update_route
    prepare_route
    route.save
  end

  def prepare_route
219
    route || build_route(source: self)
220 221 222 223
    route.path = build_full_path
    route.name = build_full_name
    @full_path = nil
    @full_name = nil
224 225
  end
end