Skip to content
Snippets Groups Projects
Verified Commit ac382b56 authored by Yorick Peterse's avatar Yorick Peterse
Browse files

Use CTEs for nested groups and authorizations

This commit introduces the usage of Common Table Expressions (CTEs) to
efficiently retrieve nested group hierarchies, without having to rely on
the "routes" table (which is an _incredibly_ inefficient way of getting
the data). This requires a patch to ActiveRecord (found in the added
initializer) to work properly as ActiveRecord doesn't support WITH
statements properly out of the box.

Unfortunately MySQL provides no efficient way of getting nested groups.
For example, the old routes setup could easily take 5-10 seconds
depending on the amount of "routes" in a database. Providing vastly
different logic for both MySQL and PostgreSQL will negatively impact the
development process. Because of this the various nested groups related
methods return empty relations when used in combination with MySQL.

For project authorizations the logic is split up into two classes:

* Gitlab::ProjectAuthorizations::WithNestedGroups
* Gitlab::ProjectAuthorizations::WithoutNestedGroups

Both classes get the fresh project authorizations (= as they should be
in the "project_authorizations" table), including nested groups if
PostgreSQL is used. The logic of these two classes is quite different
apart from their public interface. This complicates development a bit,
but unfortunately there is no way around this.

This commit also introduces Gitlab::GroupHierarchy. This class can be
used to get the ancestors and descendants of a base relation, or both by
using a UNION. This in turn is used by methods such as:

* Namespace#ancestors
* Namespace#descendants
* User#all_expanded_groups

Again this class relies on CTEs and thus only works on PostgreSQL. The
Namespace methods will return an empty relation when MySQL is used,
while User#all_expanded_groups will return only the groups a user is a
direct member of.

Performance wise the impact is quite large. For example, on GitLab.com
Namespace#descendants used to take around 580 ms to retrieve data for a
particular user. Using CTEs we are able to reduce this down to roughly 1
millisecond, returning the exact same data.

== On The Fly Refreshing

Refreshing of authorizations on the fly (= when
users.authorized_projects_populated was not set) is removed with this
commit. This simplifies the code, and ensures any queries used for
authorizations are not mutated because they are executed in a Rails
scope (e.g. Project.visible_to_user).

This commit includes a migration to schedule refreshing authorizations
for all users, ensuring all of them have their authorizations in place.
Said migration schedules users in batches of 5000, with 5 minutes
between every batch to smear the load around a bit.

== Spec Changes

This commit also introduces some changes to various specs. For example,
some specs for ProjectTeam assumed that creating a personal project
would _not_ lead to the owner having access, which is incorrect. Because
we also no longer refresh authorizations on the fly for new users some
code had to be added to the "empty_project" factory. This chunk of code
ensures that the owner's permissions are refreshed after creating the
project, something that is normally done in Projects::CreateService.
parent e261b4b8
No related branches found
No related tags found
No related merge requests found
Showing
with 588 additions and 165 deletions
Loading
Loading
@@ -84,89 +84,6 @@ module Routable
joins(:route).where(wheres.join(' OR '))
end
end
# 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
# 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?
wheres = paths.map do |path|
"#{connection.quote(path)} = routes.path
OR
#{connection.quote(path)} LIKE CONCAT(routes.path, '/%')"
end
joins(:route).where(wheres.join(' OR '))
end
end
 
def full_name
Loading
Loading
Loading
Loading
@@ -3,7 +3,11 @@ module SelectForProjectAuthorization
 
module ClassMethods
def select_for_project_authorization
select("members.user_id, projects.id AS project_id, members.access_level")
select("projects.id AS project_id, members.access_level")
end
def select_as_master_for_project_authorization
select(["projects.id AS project_id", "#{Gitlab::Access::MASTER} AS access_level"])
end
end
end
Loading
Loading
@@ -38,6 +38,10 @@ class Group < Namespace
after_save :update_two_factor_requirement
 
class << self
def supports_nested_groups?
Gitlab::Database.postgresql?
end
# Searches for groups matching the given query.
#
# This method uses ILIKE on PostgreSQL and LIKE on MySQL.
Loading
Loading
@@ -78,7 +82,7 @@ class Group < Namespace
if current_scope.joins_values.include?(:shared_projects)
joins('INNER JOIN namespaces project_namespace ON project_namespace.id = projects.namespace_id')
.where('project_namespace.share_with_group_lock = ?', false)
.select("members.user_id, projects.id AS project_id, LEAST(project_group_links.group_access, members.access_level) AS access_level")
.select("projects.id AS project_id, LEAST(project_group_links.group_access, members.access_level) AS access_level")
else
super
end
Loading
Loading
Loading
Loading
@@ -176,26 +176,22 @@ class Namespace < ActiveRecord::Base
projects.with_shared_runners.any?
end
 
# Scopes the model on ancestors of the record
# Returns all the ancestors of the current namespaces.
def ancestors
if parent_id
path = route ? route.path : full_path
paths = []
return self.class.none if !Group.supports_nested_groups? || !parent_id
 
until path.blank?
path = path.rpartition('/').first
paths << path
end
self.class.joins(:route).where('routes.path IN (?)', paths).reorder('routes.path ASC')
else
self.class.none
end
Gitlab::GroupHierarchy.
new(self.class.where(id: parent_id)).
base_and_ancestors
end
 
# Scopes the model on direct and indirect children of the record
# Returns all the descendants of the current namespace.
def descendants
self.class.joins(:route).merge(Route.inside_path(route.path)).reorder('routes.path ASC')
return self.class.none unless Group.supports_nested_groups?
Gitlab::GroupHierarchy.
new(self.class.where(parent_id: id)).
base_and_descendants
end
 
def user_ids_for_project_authorizations
Loading
Loading
Loading
Loading
@@ -6,6 +6,12 @@ class ProjectAuthorization < ActiveRecord::Base
validates :access_level, inclusion: { in: Gitlab::Access.all_values }, presence: true
validates :user, uniqueness: { scope: [:project, :access_level] }, presence: true
 
def self.select_from_union(union)
select(['project_id', 'MAX(access_level) AS access_level']).
from("(#{union.to_sql}) #{ProjectAuthorization.table_name}").
group(:project_id)
end
def self.insert_authorizations(rows, per_batch = 1000)
rows.each_slice(per_batch) do |slice|
tuples = slice.map do |tuple|
Loading
Loading
Loading
Loading
@@ -10,9 +10,12 @@ class User < ActiveRecord::Base
include Sortable
include CaseSensitivity
include TokenAuthenticatable
include IgnorableColumn
 
DEFAULT_NOTIFICATION_LEVEL = :participating
 
ignore_column :authorized_projects_populated
add_authentication_token_field :authentication_token
add_authentication_token_field :incoming_email_token
 
Loading
Loading
@@ -212,7 +215,6 @@ class User < ActiveRecord::Base
scope :blocked, -> { with_states(:blocked, :ldap_blocked) }
scope :external, -> { where(external: true) }
scope :active, -> { with_state(:active).non_internal }
scope :not_in_project, ->(project) { project.users.present? ? where("id not in (:ids)", ids: project.users.map(&:id) ) : all }
scope :without_projects, -> { where('id NOT IN (SELECT DISTINCT(user_id) FROM members WHERE user_id IS NOT NULL AND requested_at IS NULL)') }
scope :todo_authors, ->(user_id, state) { where(id: Todo.where(user_id: user_id, state: state).select(:author_id)) }
scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('last_sign_in_at', 'DESC')) }
Loading
Loading
@@ -504,23 +506,18 @@ class User < ActiveRecord::Base
Group.where("namespaces.id IN (#{union.to_sql})")
end
 
def nested_groups
Group.member_descendants(id)
end
# Returns a relation of groups the user has access to, including their parent
# and child groups (recursively).
def all_expanded_groups
Group.member_hierarchy(id)
return groups unless Group.supports_nested_groups?
Gitlab::GroupHierarchy.new(groups).all_groups
end
 
def expanded_groups_requiring_two_factor_authentication
all_expanded_groups.where(require_two_factor_authentication: true)
end
 
def nested_groups_projects
Project.joins(:namespace).where('namespaces.parent_id IS NOT NULL').
member_descendants(id)
end
def refresh_authorized_projects
Users::RefreshAuthorizedProjectsService.new(self).execute
end
Loading
Loading
@@ -529,18 +526,15 @@ class User < ActiveRecord::Base
project_authorizations.where(project_id: project_ids).delete_all
end
 
def set_authorized_projects_column
unless authorized_projects_populated
update_column(:authorized_projects_populated, true)
end
end
def authorized_projects(min_access_level = nil)
refresh_authorized_projects unless authorized_projects_populated
# We're overriding an association, so explicitly call super with no arguments or it would be passed as `force_reload` to the association
# We're overriding an association, so explicitly call super with no
# arguments or it would be passed as `force_reload` to the association
projects = super()
projects = projects.where('project_authorizations.access_level >= ?', min_access_level) if min_access_level
if min_access_level
projects = projects.
where('project_authorizations.access_level >= ?', min_access_level)
end
 
projects
end
Loading
Loading
Loading
Loading
@@ -73,12 +73,11 @@ module Users
# remove - The IDs of the authorization rows to remove.
# add - Rows to insert in the form `[user id, project id, access level]`
def update_authorizations(remove = [], add = [])
return if remove.empty? && add.empty? && user.authorized_projects_populated
return if remove.empty? && add.empty?
 
User.transaction do
user.remove_project_authorizations(remove) unless remove.empty?
ProjectAuthorization.insert_authorizations(add) unless add.empty?
user.set_authorized_projects_column
end
 
# Since we batch insert authorization rows, Rails' associations may get
Loading
Loading
@@ -101,38 +100,13 @@ module Users
end
 
def fresh_authorizations
ProjectAuthorization.
unscoped.
select('project_id, MAX(access_level) AS access_level').
from("(#{project_authorizations_union.to_sql}) #{ProjectAuthorization.table_name}").
group(:project_id)
end
private
# Returns a union query of projects that the user is authorized to access
def project_authorizations_union
relations = [
# Personal projects
user.personal_projects.select("#{user.id} AS user_id, projects.id AS project_id, #{Gitlab::Access::MASTER} AS access_level"),
# Projects the user is a member of
user.projects.select_for_project_authorization,
# Projects of groups the user is a member of
user.groups_projects.select_for_project_authorization,
# Projects of subgroups of groups the user is a member of
user.nested_groups_projects.select_for_project_authorization,
# Projects shared with groups the user is a member of
user.groups.joins(:shared_projects).select_for_project_authorization,
# Projects shared with subgroups of groups the user is a member of
user.nested_groups.joins(:shared_projects).select_for_project_authorization
]
klass = if Group.supports_nested_groups?
Gitlab::ProjectAuthorizations::WithNestedGroups
else
Gitlab::ProjectAuthorizations::WithoutNestedGroups
end
 
Gitlab::SQL::Union.new(relations)
klass.new(user).calculate
end
end
end
# Adds support for WITH statements when using PostgreSQL. The code here is taken
# from https://github.com/shmay/ctes_in_my_pg which at the time of writing has
# not been pushed to RubyGems. The license of this repository is as follows:
#
# The MIT License (MIT)
#
# Copyright (c) 2012 Dan McClain
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
module ActiveRecord
class Relation
class Merger # :nodoc:
def normal_values
NORMAL_VALUES + [:with]
end
end
end
end
module ActiveRecord::Querying
delegate :with, to: :all
end
module ActiveRecord
class Relation
# WithChain objects act as placeholder for queries in which #with does not have any parameter.
# In this case, #with must be chained with #recursive to return a new relation.
class WithChain
def initialize(scope)
@scope = scope
end
# Returns a new relation expressing WITH RECURSIVE
def recursive(*args)
@scope.with_values += args
@scope.recursive_value = true
@scope
end
end
def with_values
@values[:with] || []
end
def with_values=(values)
raise ImmutableRelation if @loaded
@values[:with] = values
end
def recursive_value=(value)
raise ImmutableRelation if @loaded
@values[:recursive] = value
end
def recursive_value
@values[:recursive]
end
def with(opts = :chain, *rest)
if opts == :chain
WithChain.new(spawn)
elsif opts.blank?
self
else
spawn.with!(opts, *rest)
end
end
def with!(opts = :chain, *rest) # :nodoc:
if opts == :chain
WithChain.new(self)
else
self.with_values += [opts] + rest
self
end
end
def build_arel
arel = super()
build_with(arel) if @values[:with]
arel
end
def build_with(arel)
with_statements = with_values.flat_map do |with_value|
case with_value
when String
with_value
when Hash
with_value.map do |name, expression|
case expression
when String
select = Arel::Nodes::SqlLiteral.new "(#{expression})"
when ActiveRecord::Relation, Arel::SelectManager
select = Arel::Nodes::SqlLiteral.new "(#{expression.to_sql})"
end
Arel::Nodes::As.new Arel::Nodes::SqlLiteral.new("\"#{name}\""), select
end
when Arel::Nodes::As
with_value
end
end
unless with_statements.empty?
if recursive_value
arel.with :recursive, with_statements
else
arel.with with_statements
end
end
end
end
end
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class RescheduleProjectAuthorizations < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
class User < ActiveRecord::Base
self.table_name = 'users'
end
def up
offset = 0
batch = 5000
start = Time.now
loop do
relation = User.where('id > ?', offset)
user_ids = relation.limit(batch).reorder(id: :asc).pluck(:id)
break if user_ids.empty?
offset = user_ids.last
# This will schedule each batch 5 minutes after the previous batch was
# scheduled. This smears out the load over time, instead of immediately
# scheduling a million jobs.
Sidekiq::Client.push_bulk(
'queue' => 'authorized_projects',
'args' => user_ids.zip,
'class' => 'AuthorizedProjectsWorker',
'at' => start.to_i
)
start += 5.minutes
end
end
def down
end
end
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class RemoveUsersAuthorizedProjectsPopulated < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
disable_ddl_transaction!
def change
remove_column :users, :authorized_projects_populated, :boolean
end
end
Loading
Loading
@@ -1352,7 +1352,6 @@ ActiveRecord::Schema.define(version: 20170508190732) do
t.boolean "external", default: false
t.string "incoming_email_token"
t.string "organization"
t.boolean "authorized_projects_populated"
t.boolean "require_two_factor_authentication_from_group", default: false, null: false
t.integer "two_factor_grace_period", default: 48, null: false
t.boolean "ghost"
Loading
Loading
module Gitlab
# Retrieving of parent or child groups based on a base ActiveRecord relation.
#
# This class uses recursive CTEs and as a result will only work on PostgreSQL.
class GroupHierarchy
attr_reader :base, :model
# base - An instance of ActiveRecord::Relation for which to get parent or
# child groups.
def initialize(base)
@base = base
@model = base.model
end
# Returns a relation that includes the base set of groups and all their
# ancestors (recursively).
def base_and_ancestors
base_and_ancestors_cte.apply_to(model.all)
end
# Returns a relation that includes the base set of groups and all their
# descendants (recursively).
def base_and_descendants
base_and_descendants_cte.apply_to(model.all)
end
# Returns a relation that includes the base groups, their ancestors, and the
# descendants of the base groups.
#
# The resulting query will roughly look like the following:
#
# WITH RECURSIVE ancestors AS ( ... ),
# descendants AS ( ... )
# SELECT *
# FROM (
# SELECT *
# FROM ancestors namespaces
#
# UNION
#
# SELECT *
# FROM descendants namespaces
# ) groups;
#
# Using this approach allows us to further add criteria to the relation with
# Rails thinking it's selecting data the usual way.
def all_groups
ancestors = base_and_ancestors_cte
descendants = base_and_descendants_cte
ancestors_table = ancestors.alias_to(groups_table)
descendants_table = descendants.alias_to(groups_table)
union = SQL::Union.new([model.unscoped.from(ancestors_table),
model.unscoped.from(descendants_table)])
model.
unscoped.
with.
recursive(ancestors.to_arel, descendants.to_arel).
from("(#{union.to_sql}) #{model.table_name}")
end
private
def base_and_ancestors_cte
cte = SQL::RecursiveCTE.new(:base_and_ancestors)
cte << base.except(:order)
# Recursively get all the ancestors of the base set.
cte << model.
from([groups_table, cte.table]).
where(groups_table[:id].eq(cte.table[:parent_id])).
except(:order)
cte
end
def base_and_descendants_cte
cte = SQL::RecursiveCTE.new(:base_and_descendants)
cte << base.except(:order)
# Recursively get all the descendants of the base set.
cte << model.
from([groups_table, cte.table]).
where(groups_table[:parent_id].eq(cte.table[:id])).
except(:order)
cte
end
def groups_table
model.arel_table
end
end
end
module Gitlab
module ProjectAuthorizations
# Calculating new project authorizations when supporting nested groups.
#
# This class relies on Common Table Expressions to efficiently get all data,
# including data for nested groups. As a result this class can only be used
# on PostgreSQL.
class WithNestedGroups
attr_reader :user
# user - The User object for which to calculate the authorizations.
def initialize(user)
@user = user
end
def calculate
cte = recursive_cte
cte_alias = cte.table.alias(Group.table_name)
projects = Project.arel_table
links = ProjectGroupLink.arel_table
# These queries don't directly use the user object so they don't depend
# on the state of said object, ensuring the produced queries are always
# the same.
relations = [
# The project a user has direct access to.
user.projects.select_for_project_authorization,
# The personal projects of the user.
user.personal_projects.select_as_master_for_project_authorization,
# Projects that belong directly to any of the groups the user has
# access to.
Namespace.
unscoped.
select([alias_as_column(projects[:id], 'project_id'),
cte_alias[:access_level]]).
from(cte_alias).
joins(:projects),
# Projects shared with any of the namespaces the user has access to.
Namespace.
unscoped.
select([links[:project_id],
least(cte_alias[:access_level],
links[:group_access],
'access_level')]).
from(cte_alias).
joins('INNER JOIN project_group_links ON project_group_links.group_id = namespaces.id').
joins('INNER JOIN projects ON projects.id = project_group_links.project_id').
joins('INNER JOIN namespaces p_ns ON p_ns.id = projects.namespace_id').
where('p_ns.share_with_group_lock IS FALSE')
]
union = Gitlab::SQL::Union.new(relations)
ProjectAuthorization.
unscoped.
with.
recursive(cte.to_arel).
select_from_union(union)
end
private
# Builds a recursive CTE that gets all the groups the current user has
# access to, including any nested groups.
def recursive_cte
cte = Gitlab::SQL::RecursiveCTE.new(:namespaces_cte)
members = Member.arel_table
namespaces = Namespace.arel_table
# Namespaces the user is a member of.
cte << user.groups.
select([namespaces[:id], members[:access_level]]).
except(:order)
# Sub groups of any groups the user is a member of.
cte << Group.select([namespaces[:id],
greatest(members[:access_level],
cte.table[:access_level], 'access_level')]).
joins(join_cte(cte)).
joins(join_members).
except(:order)
cte
end
# Builds a LEFT JOIN to join optional memberships onto the CTE.
def join_members
members = Member.arel_table
namespaces = Namespace.arel_table
cond = members[:source_id].
eq(namespaces[:id]).
and(members[:source_type].eq('Namespace')).
and(members[:requested_at].eq(nil)).
and(members[:user_id].eq(user.id))
Arel::Nodes::OuterJoin.new(members, Arel::Nodes::On.new(cond))
end
# Builds an INNER JOIN to join namespaces onto the CTE.
def join_cte(cte)
namespaces = Namespace.arel_table
cond = cte.table[:id].eq(namespaces[:parent_id])
Arel::Nodes::InnerJoin.new(cte.table, Arel::Nodes::On.new(cond))
end
def greatest(left, right, column_alias)
sql_function('GREATEST', [left, right], column_alias)
end
def least(left, right, column_alias)
sql_function('LEAST', [left, right], column_alias)
end
def sql_function(name, args, column_alias)
alias_as_column(Arel::Nodes::NamedFunction.new(name, args), column_alias)
end
def alias_as_column(value, alias_to)
Arel::Nodes::As.new(value, Arel::Nodes::SqlLiteral.new(alias_to))
end
end
end
end
module Gitlab
module ProjectAuthorizations
# Calculating new project authorizations when not supporting nested groups.
class WithoutNestedGroups
attr_reader :user
# user - The User object for which to calculate the authorizations.
def initialize(user)
@user = user
end
def calculate
relations = [
# Projects the user is a direct member of
user.projects.select_for_project_authorization,
# Personal projects
user.personal_projects.select_as_master_for_project_authorization,
# Projects of groups the user is a member of
user.groups_projects.select_for_project_authorization,
# Projects shared with groups the user is a member of
user.groups.joins(:shared_projects).select_for_project_authorization
]
union = Gitlab::SQL::Union.new(relations)
ProjectAuthorization.
unscoped.
select_from_union(union)
end
end
end
end
module Gitlab
module SQL
# Class for easily building recursive CTE statements.
#
# Example:
#
# cte = RecursiveCTE.new(:my_cte_name)
# ns = Arel::Table.new(:namespaces)
#
# cte << Namespace.
# where(ns[:parent_id].eq(some_namespace_id))
#
# cte << Namespace.
# from([ns, cte.table]).
# where(ns[:parent_id].eq(cte.table[:id]))
#
# Namespace.with.
# recursive(cte.to_arel).
# from(cte.alias_to(ns))
class RecursiveCTE
attr_reader :table
# name - The name of the CTE as a String or Symbol.
def initialize(name)
@table = Arel::Table.new(name)
@queries = []
end
# Adds a query to the body of the CTE.
#
# relation - The relation object to add to the body of the CTE.
def <<(relation)
@queries << relation
end
# Returns the Arel relation for this CTE.
def to_arel
sql = Arel::Nodes::SqlLiteral.new(Union.new(@queries).to_sql)
Arel::Nodes::As.new(table, Arel::Nodes::Grouping.new(sql))
end
# Returns an "AS" statement that aliases the CTE name as the given table
# name. This allows one to trick ActiveRecord into thinking it's selecting
# from an actual table, when in reality it's selecting from a CTE.
#
# alias_table - The Arel table to use as the alias.
def alias_to(alias_table)
Arel::Nodes::As.new(table, alias_table)
end
# Applies the CTE to the given relation, returning a new one that will
# query from it.
def apply_to(relation)
relation.except(:where).
with.
recursive(to_arel).
from(alias_to(relation.model.arel_table))
end
end
end
end
Loading
Loading
@@ -22,7 +22,7 @@ describe AutocompleteController do
let(:body) { JSON.parse(response.body) }
 
it { expect(body).to be_kind_of(Array) }
it { expect(body.size).to eq 1 }
it { expect(body.size).to eq 2 }
it { expect(body.map { |u| u["username"] }).to include(user.username) }
end
 
Loading
Loading
@@ -80,8 +80,8 @@ describe AutocompleteController do
end
 
it { expect(body).to be_kind_of(Array) }
it { expect(body.size).to eq 2 }
it { expect(body.map { |u| u['username'] }).to match_array([user.username, non_member.username]) }
it { expect(body.size).to eq 3 }
it { expect(body.map { |u| u['username'] }).to include(user.username, non_member.username) }
end
end
 
Loading
Loading
@@ -108,7 +108,7 @@ describe AutocompleteController do
end
 
it { expect(body).to be_kind_of(Array) }
it { expect(body.size).to eq 1 }
it { expect(body.size).to eq 2 }
end
 
describe 'GET #users with project' do
Loading
Loading
Loading
Loading
@@ -2,7 +2,7 @@ require 'spec_helper'
 
describe Projects::MergeRequestsController do
let(:project) { create(:project) }
let(:user) { create(:user) }
let(:user) { project.owner }
let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) }
let(:merge_request_with_conflicts) do
create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start', source_project: project) do |mr|
Loading
Loading
@@ -12,7 +12,6 @@ describe Projects::MergeRequestsController do
 
before do
sign_in(user)
project.team << [user, :master]
end
 
describe 'GET new' do
Loading
Loading
@@ -292,6 +291,8 @@ describe Projects::MergeRequestsController do
end
 
context 'when user cannot access' do
let(:user) { create(:user) }
before do
project.add_reporter(user)
xhr :post, :merge, base_params
Loading
Loading
@@ -448,6 +449,8 @@ describe Projects::MergeRequestsController do
end
 
describe "DELETE destroy" do
let(:user) { create(:user) }
it "denies access to users unless they're admin or project owner" do
delete :destroy, namespace_id: project.namespace, project_id: project, id: merge_request.iid
 
Loading
Loading
Loading
Loading
@@ -107,6 +107,18 @@ FactoryGirl.define do
merge_requests_access_level: merge_requests_access_level,
repository_access_level: evaluator.repository_access_level
)
# Normally the class Projects::CreateService is used for creating
# projects, and this class takes care of making sure the owner and current
# user have access to the project. Our specs don't use said service class,
# thus we must manually refresh things here.
owner = project.owner
if owner && owner.is_a?(User) && !project.pending_delete
project.members.create!(user: owner, access_level: Gitlab::Access::MASTER)
end
project.group&.refresh_members_authorized_projects
end
end
 
Loading
Loading
Loading
Loading
@@ -22,7 +22,7 @@ feature 'Group name toggle', feature: true, js: true do
expect(page).not_to have_css('.group-name-toggle')
end
 
it 'is present if the title is longer than the container' do
it 'is present if the title is longer than the container', :nested_groups do
visit group_path(nested_group_3)
title_width = page.evaluate_script("$('.title')[0].offsetWidth")
 
Loading
Loading
@@ -35,7 +35,7 @@ feature 'Group name toggle', feature: true, js: true do
expect(title_width).to be > container_width
end
 
it 'should show the full group namespace when toggled' do
it 'should show the full group namespace when toggled', :nested_groups do
page_height = page.current_window.size[1]
page.current_window.resize_to(SMALL_SCREEN, page_height)
visit group_path(nested_group_3)
Loading
Loading
Loading
Loading
@@ -12,7 +12,7 @@ feature 'Groups members list', feature: true do
login_as(user1)
end
 
scenario 'show members from current group and parent' do
scenario 'show members from current group and parent', :nested_groups do
group.add_developer(user1)
nested_group.add_developer(user2)
 
Loading
Loading
@@ -22,7 +22,7 @@ feature 'Groups members list', feature: true do
expect(second_row.text).to include(user2.name)
end
 
scenario 'show user once if member of both current group and parent' do
scenario 'show user once if member of both current group and parent', :nested_groups do
group.add_developer(user1)
nested_group.add_developer(user1)
 
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment