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 :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
|
||||
|
|
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 :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,
|
||||
|
|
|
@ -14,6 +14,11 @@ module Types
|
|||
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,
|
||||
|
|
|
@ -16,6 +16,8 @@ class ProjectStatistics < ApplicationRecord
|
|||
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
|
||||
|
|
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. `group` : Only basic group information is currently supported.
|
||||
1. `namespace` : Within a namespace it is also possible to fetch `projects`.
|
||||
|
||||
### 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
|
||||
it { expect(described_class.graphql_name).to eq('Namespace') }
|
||||
|
||||
it { expect(described_class).to have_graphql_field(:projects) }
|
||||
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(:repository) }
|
||||
|
||||
it { is_expected.to have_graphql_field(:statistics) }
|
||||
end
|
||||
|
|
|
@ -5,7 +5,17 @@ describe GitlabSchema.types['Query'] do
|
|||
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'] }
|
||||
|
|
|
@ -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) }
|
||||
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!(
|
||||
|
|
|
@ -86,17 +86,18 @@ describe 'getting group information' do
|
|||
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
|
||||
|
||||
|
|
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