Skip to content
Snippets Groups Projects
Commit 83a8b779 authored by Alessio Caiazza's avatar Alessio Caiazza
Browse files

Add Namespace and ProjectStatistics to GraphQL API

We can query namespaces, and nested projects.

Projects now exposes statistics
parent ac03f30c
No related branches found
No related tags found
No related merge requests found
Showing
with 362 additions and 5 deletions
# 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
# 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
Loading
Loading
@@ -15,5 +15,10 @@ class NamespaceType < BaseObject
field :visibility, GraphQL::STRING_TYPE, null: true
field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true, method: :lfs_enabled?
field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true
field :projects,
Types::ProjectType.connection_type,
null: false,
resolver: ::Resolvers::NamespaceProjectsResolver
end
end
# 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
Loading
Loading
@@ -69,6 +69,10 @@ class ProjectType < BaseObject
field :namespace, Types::NamespaceType, null: false
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 :merge_requests,
Loading
Loading
Loading
Loading
@@ -14,6 +14,11 @@ class QueryType < ::Types::BaseObject
resolver: Resolvers::GroupResolver,
description: "Find a group"
 
field :namespace, Types::NamespaceType,
null: true,
resolver: Resolvers::NamespaceResolver,
description: "Find a namespace"
field :metadata, Types::MetadataType,
null: true,
resolver: Resolvers::MetadataResolver,
Loading
Loading
Loading
Loading
@@ -16,6 +16,8 @@ def wiki_size
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
 
scope :for_project_ids, ->(project_ids) { where(project_id: project_ids) }
def total_repository_size
repository_size + lfs_objects_size
end
Loading
Loading
---
title: Add Namespace and ProjectStatistics to GraphQL API
merge_request: 28277
author:
type: added
Loading
Loading
@@ -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. `group` : Only basic group information is currently supported.
1. `namespace` : Within a namespace it is also possible to fetch `projects`.
 
### Multiplex queries
 
Loading
Loading
# 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
# 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
Loading
Loading
@@ -4,4 +4,6 @@
 
describe GitlabSchema.types['Namespace'] do
it { expect(described_class.graphql_name).to eq('Namespace') }
it { expect(described_class).to have_graphql_field(:projects) }
end
# 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
Loading
Loading
@@ -19,4 +19,6 @@
it { is_expected.to have_graphql_field(:pipelines) }
 
it { is_expected.to have_graphql_field(:repository) }
it { is_expected.to have_graphql_field(:statistics) }
end
Loading
Loading
@@ -5,7 +5,17 @@
expect(described_class.graphql_name).to eq('Query')
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
subject { described_class.fields['project'] }
Loading
Loading
# 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
Loading
Loading
@@ -11,6 +11,20 @@
it { is_expected.to belong_to(:namespace) }
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
it "support values up to 8 exabytes" do
statistics.update!(
Loading
Loading
Loading
Loading
@@ -86,17 +86,18 @@ def group_query(group)
end
 
it 'avoids N+1 queries' do
post_graphql(group_query(group1), current_user: admin)
control_count = ActiveRecord::QueryRecorder.new do
post_graphql(group_query(group1), current_user: admin)
end.count
 
create(:project, namespace: group1)
queries = [{ query: group_query(group1) },
{ query: group_query(group2) }]
 
expect do
post_graphql(group_query(group1), current_user: admin)
post_multiplex(queries, current_user: admin)
end.not_to exceed_query_limit(control_count)
expect(graphql_errors).to contain_exactly(nil, nil)
end
end
 
Loading
Loading
# 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
# 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
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