Add Namespace and ProjectStatistics to GraphQL API

We can query namespaces, and nested projects.

Projects now exposes statistics
This commit is contained in:
Alessio Caiazza 2019-05-12 16:10:46 -05:00
parent ac03f30cd9
commit 83a8b77961
20 changed files with 362 additions and 5 deletions

View file

@ -0,0 +1,33 @@
# frozen_string_literal: true
module Resolvers
class NamespaceProjectsResolver < BaseResolver
argument :include_subgroups, GraphQL::BOOLEAN_TYPE,
required: false,
default_value: false,
description: 'Include also subgroup projects'
type Types::ProjectType, null: true
alias_method :namespace, :object
def resolve(include_subgroups:)
# The namespace could have been loaded in batch by `BatchLoader`.
# At this point we need the `id` or the `full_path` of the namespace
# to query for projects, so make sure it's loaded and not `nil` before continuing.
namespace.sync if namespace.respond_to?(:sync)
return Project.none if namespace.nil?
if include_subgroups
namespace.all_projects.with_route
else
namespace.projects.with_route
end
end
def self.resolver_complexity(args, child_complexity:)
complexity = super
complexity + 10
end
end
end

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
module Resolvers
class NamespaceResolver < BaseResolver
prepend FullPathResolver
type Types::NamespaceType, null: true
def resolve(full_path:)
model_by_full_path(Namespace, full_path)
end
end
end

View file

@ -15,5 +15,10 @@ module Types
field :visibility, GraphQL::STRING_TYPE, null: true field :visibility, GraphQL::STRING_TYPE, null: true
field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true, method: :lfs_enabled? field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true, method: :lfs_enabled?
field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true
field :projects,
Types::ProjectType.connection_type,
null: false,
resolver: ::Resolvers::NamespaceProjectsResolver
end end
end end

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
module Types
class ProjectStatisticsType < BaseObject
graphql_name 'ProjectStatistics'
field :commit_count, GraphQL::INT_TYPE, null: false
field :storage_size, GraphQL::INT_TYPE, null: false
field :repository_size, GraphQL::INT_TYPE, null: false
field :lfs_objects_size, GraphQL::INT_TYPE, null: false
field :build_artifacts_size, GraphQL::INT_TYPE, null: false
field :packages_size, GraphQL::INT_TYPE, null: false
end
end

View file

@ -69,6 +69,10 @@ module Types
field :namespace, Types::NamespaceType, null: false field :namespace, Types::NamespaceType, null: false
field :group, Types::GroupType, null: true field :group, Types::GroupType, null: true
field :statistics, Types::ProjectStatisticsType,
null: false,
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchProjectStatisticsLoader.new(obj.id).find }
field :repository, Types::RepositoryType, null: false field :repository, Types::RepositoryType, null: false
field :merge_requests, field :merge_requests,

View file

@ -14,6 +14,11 @@ module Types
resolver: Resolvers::GroupResolver, resolver: Resolvers::GroupResolver,
description: "Find a group" description: "Find a group"
field :namespace, Types::NamespaceType,
null: true,
resolver: Resolvers::NamespaceResolver,
description: "Find a namespace"
field :metadata, Types::MetadataType, field :metadata, Types::MetadataType,
null: true, null: true,
resolver: Resolvers::MetadataResolver, resolver: Resolvers::MetadataResolver,

View file

@ -16,6 +16,8 @@ class ProjectStatistics < ApplicationRecord
COLUMNS_TO_REFRESH = [:repository_size, :wiki_size, :lfs_objects_size, :commit_count].freeze COLUMNS_TO_REFRESH = [:repository_size, :wiki_size, :lfs_objects_size, :commit_count].freeze
INCREMENTABLE_COLUMNS = { build_artifacts_size: %i[storage_size], packages_size: %i[storage_size] }.freeze INCREMENTABLE_COLUMNS = { build_artifacts_size: %i[storage_size], packages_size: %i[storage_size] }.freeze
scope :for_project_ids, ->(project_ids) { where(project_id: project_ids) }
def total_repository_size def total_repository_size
repository_size + lfs_objects_size repository_size + lfs_objects_size
end end

View file

@ -0,0 +1,5 @@
---
title: Add Namespace and ProjectStatistics to GraphQL API
merge_request: 28277
author:
type: added

View file

@ -47,6 +47,7 @@ A first iteration of a GraphQL API includes the following queries
1. `project` : Within a project it is also possible to fetch a `mergeRequest` by IID. 1. `project` : Within a project it is also possible to fetch a `mergeRequest` by IID.
1. `group` : Only basic group information is currently supported. 1. `group` : Only basic group information is currently supported.
1. `namespace` : Within a namespace it is also possible to fetch `projects`.
### Multiplex queries ### Multiplex queries

View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
module Gitlab
module Graphql
module Loaders
class BatchProjectStatisticsLoader
attr_reader :project_id
def initialize(project_id)
@project_id = project_id
end
def find
BatchLoader.for(project_id).batch do |project_ids, loader|
ProjectStatistics.for_project_ids(project_ids).each do |statistics|
loader.call(statistics.project_id, statistics)
end
end
end
end
end
end
end

View file

@ -0,0 +1,69 @@
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::NamespaceProjectsResolver, :nested_groups do
include GraphqlHelpers
let(:current_user) { create(:user) }
context "with a group" do
let(:group) { create(:group) }
let(:namespace) { group }
let(:project1) { create(:project, namespace: namespace) }
let(:project2) { create(:project, namespace: namespace) }
let(:nested_group) { create(:group, parent: group) }
let(:nested_project) { create(:project, group: nested_group) }
before do
project1.add_developer(current_user)
project2.add_developer(current_user)
nested_project.add_developer(current_user)
end
describe '#resolve' do
it 'finds all projects' do
expect(resolve_projects).to contain_exactly(project1, project2)
end
it 'finds all projects including the subgroups' do
expect(resolve_projects(include_subgroups: true)).to contain_exactly(project1, project2, nested_project)
end
context 'with an user namespace' do
let(:namespace) { current_user.namespace }
it 'finds all projects' do
expect(resolve_projects).to contain_exactly(project1, project2)
end
it 'finds all projects including the subgroups' do
expect(resolve_projects(include_subgroups: true)).to contain_exactly(project1, project2)
end
end
end
end
context "when passing a non existent, batch loaded namespace" do
let(:namespace) do
BatchLoader.for("non-existent-path").batch do |_fake_paths, loader, _|
loader.call("non-existent-path", nil)
end
end
it "returns nil without breaking" do
expect(resolve_projects).to be_empty
end
end
it 'has an high complexity regardless of arguments' do
field = Types::BaseField.new(name: 'test', type: GraphQL::STRING_TYPE, resolver_class: described_class, null: false, max_page_size: 100)
expect(field.to_graphql.complexity.call({}, {}, 1)).to eq 24
expect(field.to_graphql.complexity.call({}, { include_subgroups: true }, 1)).to eq 24
end
def resolve_projects(args = { include_subgroups: false }, context = { current_user: current_user })
resolve(described_class, obj: namespace, args: args, ctx: context)
end
end

View file

@ -4,4 +4,6 @@ require 'spec_helper'
describe GitlabSchema.types['Namespace'] do describe GitlabSchema.types['Namespace'] do
it { expect(described_class.graphql_name).to eq('Namespace') } it { expect(described_class.graphql_name).to eq('Namespace') }
it { expect(described_class).to have_graphql_field(:projects) }
end end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['ProjectStatistics'] do
it "has all the required fields" do
is_expected.to have_graphql_fields(:storage_size, :repository_size, :lfs_objects_size,
:build_artifacts_size, :packages_size, :commit_count)
end
end

View file

@ -19,4 +19,6 @@ describe GitlabSchema.types['Project'] do
it { is_expected.to have_graphql_field(:pipelines) } it { is_expected.to have_graphql_field(:pipelines) }
it { is_expected.to have_graphql_field(:repository) } it { is_expected.to have_graphql_field(:repository) }
it { is_expected.to have_graphql_field(:statistics) }
end end

View file

@ -5,7 +5,17 @@ describe GitlabSchema.types['Query'] do
expect(described_class.graphql_name).to eq('Query') expect(described_class.graphql_name).to eq('Query')
end end
it { is_expected.to have_graphql_fields(:project, :group, :echo, :metadata) } it { is_expected.to have_graphql_fields(:project, :namespace, :group, :echo, :metadata) }
describe 'namespace field' do
subject { described_class.fields['namespace'] }
it 'finds namespaces by full path' do
is_expected.to have_graphql_arguments(:full_path)
is_expected.to have_graphql_type(Types::NamespaceType)
is_expected.to have_graphql_resolver(Resolvers::NamespaceResolver)
end
end
describe 'project field' do describe 'project field' do
subject { described_class.fields['project'] } subject { described_class.fields['project'] }

View file

@ -0,0 +1,18 @@
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Graphql::Loaders::BatchProjectStatisticsLoader do
describe '#find' do
it 'only queries once for project statistics' do
stats = create_list(:project_statistics, 2)
project1 = stats.first.project
project2 = stats.last.project
expect do
described_class.new(project1.id).find
described_class.new(project2.id).find
end.not_to exceed_query_limit(1)
end
end
end

View file

@ -11,6 +11,20 @@ describe ProjectStatistics do
it { is_expected.to belong_to(:namespace) } it { is_expected.to belong_to(:namespace) }
end end
describe 'scopes' do
describe '.for_project_ids' do
it 'returns only requested projects' do
stats = create_list(:project_statistics, 3)
project_ids = stats[0..1].map { |s| s.project_id }
expected_ids = stats[0..1].map { |s| s.id }
requested_stats = described_class.for_project_ids(project_ids).pluck(:id)
expect(requested_stats).to eq(expected_ids)
end
end
end
describe 'statistics columns' do describe 'statistics columns' do
it "support values up to 8 exabytes" do it "support values up to 8 exabytes" do
statistics.update!( statistics.update!(

View file

@ -86,17 +86,18 @@ describe 'getting group information' do
end end
it 'avoids N+1 queries' do it 'avoids N+1 queries' do
post_graphql(group_query(group1), current_user: admin)
control_count = ActiveRecord::QueryRecorder.new do control_count = ActiveRecord::QueryRecorder.new do
post_graphql(group_query(group1), current_user: admin) post_graphql(group_query(group1), current_user: admin)
end.count end.count
create(:project, namespace: group1) queries = [{ query: group_query(group1) },
{ query: group_query(group2) }]
expect do expect do
post_graphql(group_query(group1), current_user: admin) post_multiplex(queries, current_user: admin)
end.not_to exceed_query_limit(control_count) end.not_to exceed_query_limit(control_count)
expect(graphql_errors).to contain_exactly(nil, nil)
end end
end end

View file

@ -0,0 +1,82 @@
# frozen_string_literal: true
require 'spec_helper'
describe 'getting projects', :nested_groups do
include GraphqlHelpers
let(:group) { create(:group) }
let!(:project) { create(:project, namespace: subject) }
let(:nested_group) { create(:group, parent: group) }
let!(:nested_project) { create(:project, group: nested_group) }
let!(:public_project) { create(:project, :public, namespace: subject) }
let(:user) { create(:user) }
let(:include_subgroups) { true }
subject { group }
let(:query) do
graphql_query_for(
'namespace',
{ 'fullPath' => subject.full_path },
<<~QUERY
projects(includeSubgroups: #{include_subgroups}) {
edges {
node {
#{all_graphql_fields_for('Project')}
}
}
}
QUERY
)
end
before do
group.add_owner(user)
end
shared_examples 'a graphql namespace' do
it_behaves_like 'a working graphql query' do
before do
post_graphql(query, current_user: user)
end
end
it "includes the packages size if the user can read the statistics" do
post_graphql(query, current_user: user)
count = if include_subgroups
subject.all_projects.count
else
subject.projects.count
end
expect(graphql_data['namespace']['projects']['edges'].size).to eq(count)
end
context 'with no user' do
it 'finds only public projects' do
post_graphql(query, current_user: nil)
expect(graphql_data['namespace']['projects']['edges'].size).to eq(1)
project = graphql_data['namespace']['projects']['edges'][0]['node']
expect(project['id']).to eq(public_project.id.to_s)
end
end
end
it_behaves_like 'a graphql namespace'
context 'when the namespace is a user' do
subject { user.namespace }
let(:include_subgroups) { false }
it_behaves_like 'a graphql namespace'
end
context 'when not including subgroups' do
let(:include_subgroups) { false }
it_behaves_like 'a graphql namespace'
end
end

View file

@ -0,0 +1,43 @@
# frozen_string_literal: true
require 'spec_helper'
describe 'rendering namespace statistics' do
include GraphqlHelpers
let(:project) { create(:project) }
let!(:project_statistics) { create(:project_statistics, project: project, packages_size: 5.megabytes) }
let(:user) { create(:user) }
let(:query) do
graphql_query_for('project',
{ 'fullPath' => project.full_path },
"statistics { #{all_graphql_fields_for('ProjectStatistics')} }")
end
before do
project.add_reporter(user)
end
it_behaves_like 'a working graphql query' do
before do
post_graphql(query, current_user: user)
end
end
it "includes the packages size if the user can read the statistics" do
post_graphql(query, current_user: user)
expect(graphql_data['project']['statistics']['packagesSize']).to eq(5.megabytes)
end
context 'when the project is public' do
let(:project) { create(:project, :public) }
it 'includes the statistics regardless of the user' do
post_graphql(query, current_user: nil)
expect(graphql_data['project']['statistics']).to be_present
end
end
end