Add Namespace and ProjectStatistics to GraphQL API
We can query namespaces, and nested projects. Projects now exposes statistics
This commit is contained in:
parent
ac03f30cd9
commit
83a8b77961
20 changed files with 362 additions and 5 deletions
33
app/graphql/resolvers/namespace_projects_resolver.rb
Normal file
33
app/graphql/resolvers/namespace_projects_resolver.rb
Normal 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
|
13
app/graphql/resolvers/namespace_resolver.rb
Normal file
13
app/graphql/resolvers/namespace_resolver.rb
Normal 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
|
|
@ -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
|
||||||
|
|
15
app/graphql/types/project_statistics_type.rb
Normal file
15
app/graphql/types/project_statistics_type.rb
Normal 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
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
5
changelogs/unreleased/ac-graphql-stats.yml
Normal file
5
changelogs/unreleased/ac-graphql-stats.yml
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Add Namespace and ProjectStatistics to GraphQL API
|
||||||
|
merge_request: 28277
|
||||||
|
author:
|
||||||
|
type: added
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
69
spec/graphql/resolvers/namespace_projects_resolver_spec.rb
Normal file
69
spec/graphql/resolvers/namespace_projects_resolver_spec.rb
Normal 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
|
|
@ -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
|
10
spec/graphql/types/project_statistics_type_spec.rb
Normal file
10
spec/graphql/types/project_statistics_type_spec.rb
Normal 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
|
|
@ -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
|
||||||
|
|
|
@ -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'] }
|
||||||
|
|
|
@ -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
|
|
@ -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!(
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
82
spec/requests/api/graphql/namespace/projects_spec.rb
Normal file
82
spec/requests/api/graphql/namespace/projects_spec.rb
Normal 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
|
43
spec/requests/api/graphql/project/project_statistics_spec.rb
Normal file
43
spec/requests/api/graphql/project/project_statistics_spec.rb
Normal 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
|
Loading…
Reference in a new issue