routable.rb 6.7 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
module Routable
  extend ActiveSupport::Concern

  included do
    has_one :route, as: :source, autosave: true, dependent: :destroy
8
    has_many :redirect_routes, as: :source, autosave: true, dependent: :destroy
9 10

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

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

    before_validation do
      if full_path_changed? || full_name_changed?
        prepare_route
      end
    end
20 21 22 23 24 25 26 27 28 29
  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.
30
    def find_by_full_path(path, follow_redirects: false)
31 32 33 34
      # 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.
35 36 37 38 39 40 41 42
      #
      # Why do we do this?
      #
      # Even though we have Rails validation on Route for unique paths
      # (case-insensitive), there are old projects in our DB (and possibly
      # clients' DBs) that have the same path with different cases.
      # See https://gitlab.com/gitlab-org/gitlab-ce/issues/18603. Also note that
      # our unique index is case-sensitive in Postgres.
43 44
      binary = Gitlab::Database.mysql? ? 'BINARY' : ''
      order_sql = "(CASE WHEN #{binary} routes.path = #{connection.quote(path)} THEN 0 ELSE 1 END)"
45 46
      found = where_full_path_in([path]).reorder(order_sql).take
      return found if found
47

48 49 50 51 52 53 54
      if follow_redirects
        if Gitlab::Database.postgresql?
          joins(:redirect_routes).find_by("LOWER(redirect_routes.path) = LOWER(?)", path)
        else
          joins(:redirect_routes).find_by(path: path)
        end
      end
55 56 57 58 59 60
    end

    # Builds a relation to find multiple objects by their full paths.
    #
    # Usage:
    #
61
    #     Klass.where_full_path_in(%w{gitlab-org/gitlab-ce gitlab-org/gitlab-ee})
62 63
    #
    # Returns an ActiveRecord::Relation.
64
    def where_full_path_in(paths)
65 66 67 68 69 70
      wheres = []
      cast_lower = Gitlab::Database.postgresql?

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

71 72 73 74 75 76
        where =
          if cast_lower
            "(LOWER(routes.path) = LOWER(#{path}))"
          else
            "(routes.path = #{path})"
          end
77 78 79 80 81 82 83 84 85 86

        wheres << where
      end

      if wheres.empty?
        none
      else
        joins(:route).where(wheres.join(' OR '))
      end
    end
87 88 89 90 91 92 93 94 95 96 97 98 99 100 101

    # 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
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 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161

    # 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?

162
      wheres = paths.map do |path|
163 164 165
        "#{connection.quote(path)} = routes.path
         OR
         #{connection.quote(path)} LIKE CONCAT(routes.path, '/%')"
166 167 168 169
      end

      joins(:route).where(wheres.join(' OR '))
    end
170 171
  end

172 173 174 175 176 177 178 179 180 181
  def full_name
    if route && route.name.present?
      @full_name ||= route.name
    else
      update_route if persisted?

      build_full_name
    end
  end

182 183 184 185
  # 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.
186
  def full_path
187 188 189 190 191 192 193 194 195
    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
196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234
    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
235
    route || build_route(source: self)
236 237 238 239
    route.path = build_full_path
    route.name = build_full_name
    @full_path = nil
    @full_name = nil
240 241
  end
end