Commit 82cc94da authored by Lin Jen-Shin's avatar Lin Jen-Shin

Merge branch '13495-c-design-at-version-types' into 'master'

13495-c: Add design-at-version GraphQL entities

See merge request gitlab-org/gitlab!21276
parents c40136fb e4f91bdc
...@@ -86,4 +86,4 @@ jsdoc/ ...@@ -86,4 +86,4 @@ jsdoc/
.projections.json .projections.json
/qa/.rakeTasks /qa/.rakeTasks
webpack-dev-server.json webpack-dev-server.json
.nvimrc /.nvimrc
...@@ -57,20 +57,24 @@ class GitlabSchema < GraphQL::Schema ...@@ -57,20 +57,24 @@ class GitlabSchema < GraphQL::Schema
object.to_global_id object.to_global_id
end end
# Find an object by looking it up from its global ID, passed as a string.
#
# This is the composition of 'parse_gid' and 'find_by_gid', see these
# methods for further documentation.
def object_from_id(global_id, ctx = {}) def object_from_id(global_id, ctx = {})
expected_type = ctx[:expected_type] gid = parse_gid(global_id, ctx)
gid = GlobalID.parse(global_id)
unless gid find_by_gid(gid)
raise Gitlab::Graphql::Errors::ArgumentError, "#{global_id} is not a valid GitLab id."
end
if expected_type && !gid.model_class.ancestors.include?(expected_type)
vars = { global_id: global_id, expected_type: expected_type }
msg = _('%{global_id} is not a valid id for %{expected_type}.') % vars
raise Gitlab::Graphql::Errors::ArgumentError, msg
end end
# Find an object by looking it up from its 'GlobalID'.
#
# * For `ApplicationRecord`s, this is equivalent to
# `global_id.model_class.find(gid.model_id)`, but more efficient.
# * For classes that implement `.lazy_find(global_id)`, this class method
# will be called.
# * All other classes will use `GlobalID#find`
def find_by_gid(gid)
if gid.model_class < ApplicationRecord if gid.model_class < ApplicationRecord
Gitlab::Graphql::Loaders::BatchModelLoader.new(gid.model_class, gid.model_id).find Gitlab::Graphql::Loaders::BatchModelLoader.new(gid.model_class, gid.model_id).find
elsif gid.model_class.respond_to?(:lazy_find) elsif gid.model_class.respond_to?(:lazy_find)
...@@ -80,6 +84,38 @@ class GitlabSchema < GraphQL::Schema ...@@ -80,6 +84,38 @@ class GitlabSchema < GraphQL::Schema
end end
end end
# Parse a string to a GlobalID, raising ArgumentError if there are problems
# with it.
#
# Problems that may occur:
# * it may not be syntactically valid
# * it may not match the expected type (see below)
#
# Options:
# * :expected_type [Class] - the type of object this GlobalID should refer to.
#
# e.g.
#
# ```
# gid = GitlabSchema.parse_gid(my_string, expected_type: ::Project)
# project_id = gid.model_id
# gid.model_class == ::Project
# ```
def parse_gid(global_id, ctx = {})
expected_type = ctx[:expected_type]
gid = GlobalID.parse(global_id)
raise Gitlab::Graphql::Errors::ArgumentError, "#{global_id} is not a valid GitLab id." unless gid
if expected_type && !gid.model_class.ancestors.include?(expected_type)
vars = { global_id: global_id, expected_type: expected_type }
msg = _('%{global_id} is not a valid id for %{expected_type}.') % vars
raise Gitlab::Graphql::Errors::ArgumentError, msg
end
gid
end
private private
def max_query_complexity(ctx) def max_query_complexity(ctx)
......
...@@ -529,9 +529,9 @@ type CreateSnippetPayload { ...@@ -529,9 +529,9 @@ type CreateSnippetPayload {
snippet: Snippet snippet: Snippet
} }
type Design implements Noteable { type Design implements DesignFields & Noteable {
""" """
Diff refs of the design The diff refs for this design
""" """
diffRefs: DiffRefs! diffRefs: DiffRefs!
...@@ -561,33 +561,32 @@ type Design implements Noteable { ...@@ -561,33 +561,32 @@ type Design implements Noteable {
): DiscussionConnection! ): DiscussionConnection!
""" """
Type of change made to the design at the version specified by the `atVersion` How this design was changed in the current version
argument if supplied. Defaults to the latest version
""" """
event: DesignVersionEvent! event: DesignVersionEvent!
""" """
Filename of the design file The filename of the design
""" """
filename: String! filename: String!
""" """
Full path of the design file The full path to the design file
""" """
fullPath: String! fullPath: String!
""" """
ID of the design The ID of this design
""" """
id: ID! id: ID!
""" """
Image of the design The URL of the image
""" """
image: String! image: String!
""" """
Issue associated with the design The issue the design belongs to
""" """
issue: Issue! issue: Issue!
...@@ -617,17 +616,17 @@ type Design implements Noteable { ...@@ -617,17 +616,17 @@ type Design implements Noteable {
): NoteConnection! ): NoteConnection!
""" """
Total count of user-created notes for the design The total count of user-created notes for this design
""" """
notesCount: Int! notesCount: Int!
""" """
Project associated with the design The project the design belongs to
""" """
project: Project! project: Project!
""" """
All versions related to the design, ordered newest first All versions related to this design ordered newest first
""" """
versions( versions(
""" """
...@@ -765,6 +764,53 @@ type DesignEdge { ...@@ -765,6 +764,53 @@ type DesignEdge {
node: Design node: Design
} }
interface DesignFields {
"""
The diff refs for this design
"""
diffRefs: DiffRefs!
"""
How this design was changed in the current version
"""
event: DesignVersionEvent!
"""
The filename of the design
"""
filename: String!
"""
The full path to the design file
"""
fullPath: String!
"""
The ID of this design
"""
id: ID!
"""
The URL of the image
"""
image: String!
"""
The issue the design belongs to
"""
issue: Issue!
"""
The total count of user-created notes for this design
"""
notesCount: Int!
"""
The project the design belongs to
"""
project: Project!
}
""" """
Autogenerated input type of DesignManagementDelete Autogenerated input type of DesignManagementDelete
""" """
......
...@@ -10350,7 +10350,7 @@ ...@@ -10350,7 +10350,7 @@
"fields": [ "fields": [
{ {
"name": "diffRefs", "name": "diffRefs",
"description": "Diff refs of the design", "description": "The diff refs for this design",
"args": [ "args": [
], ],
...@@ -10425,7 +10425,7 @@ ...@@ -10425,7 +10425,7 @@
}, },
{ {
"name": "event", "name": "event",
"description": "Type of change made to the design at the version specified by the `atVersion` argument if supplied. Defaults to the latest version", "description": "How this design was changed in the current version",
"args": [ "args": [
], ],
...@@ -10443,7 +10443,7 @@ ...@@ -10443,7 +10443,7 @@
}, },
{ {
"name": "filename", "name": "filename",
"description": "Filename of the design file", "description": "The filename of the design",
"args": [ "args": [
], ],
...@@ -10461,7 +10461,7 @@ ...@@ -10461,7 +10461,7 @@
}, },
{ {
"name": "fullPath", "name": "fullPath",
"description": "Full path of the design file", "description": "The full path to the design file",
"args": [ "args": [
], ],
...@@ -10479,7 +10479,7 @@ ...@@ -10479,7 +10479,7 @@
}, },
{ {
"name": "id", "name": "id",
"description": "ID of the design", "description": "The ID of this design",
"args": [ "args": [
], ],
...@@ -10497,7 +10497,7 @@ ...@@ -10497,7 +10497,7 @@
}, },
{ {
"name": "image", "name": "image",
"description": "Image of the design", "description": "The URL of the image",
"args": [ "args": [
], ],
...@@ -10515,7 +10515,7 @@ ...@@ -10515,7 +10515,7 @@
}, },
{ {
"name": "issue", "name": "issue",
"description": "Issue associated with the design", "description": "The issue the design belongs to",
"args": [ "args": [
], ],
...@@ -10590,7 +10590,7 @@ ...@@ -10590,7 +10590,7 @@
}, },
{ {
"name": "notesCount", "name": "notesCount",
"description": "Total count of user-created notes for the design", "description": "The total count of user-created notes for this design",
"args": [ "args": [
], ],
...@@ -10608,7 +10608,7 @@ ...@@ -10608,7 +10608,7 @@
}, },
{ {
"name": "project", "name": "project",
"description": "Project associated with the design", "description": "The project the design belongs to",
"args": [ "args": [
], ],
...@@ -10626,7 +10626,7 @@ ...@@ -10626,7 +10626,7 @@
}, },
{ {
"name": "versions", "name": "versions",
"description": "All versions related to the design, ordered newest first", "description": "All versions related to this design ordered newest first",
"args": [ "args": [
{ {
"name": "after", "name": "after",
...@@ -10688,11 +10688,195 @@ ...@@ -10688,11 +10688,195 @@
"kind": "INTERFACE", "kind": "INTERFACE",
"name": "Noteable", "name": "Noteable",
"ofType": null "ofType": null
},
{
"kind": "INTERFACE",
"name": "DesignFields",
"ofType": null
} }
], ],
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "INTERFACE",
"name": "DesignFields",
"description": null,
"fields": [
{
"name": "diffRefs",
"description": "The diff refs for this design",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "DiffRefs",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "event",
"description": "How this design was changed in the current version",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "DesignVersionEvent",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "filename",
"description": "The filename of the design",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "fullPath",
"description": "The full path to the design file",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": "The ID of this design",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "image",
"description": "The URL of the image",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "issue",
"description": "The issue the design belongs to",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Issue",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "notesCount",
"description": "The total count of user-created notes for this design",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "project",
"description": "The project the design belongs to",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Project",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": null,
"enumValues": null,
"possibleTypes": [
{
"kind": "OBJECT",
"name": "Design",
"ofType": null
}
]
},
{ {
"kind": "ENUM", "kind": "ENUM",
"name": "DesignVersionEvent", "name": "DesignVersionEvent",
......
...@@ -104,15 +104,15 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph ...@@ -104,15 +104,15 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph
| Name | Type | Description | | Name | Type | Description |
| --- | ---- | ---------- | | --- | ---- | ---------- |
| `id` | ID! | ID of the design | | `id` | ID! | The ID of this design |
| `project` | Project! | Project associated with the design | | `project` | Project! | The project the design belongs to |
| `issue` | Issue! | Issue associated with the design | | `issue` | Issue! | The issue the design belongs to |
| `notesCount` | Int! | Total count of user-created notes for the design | | `filename` | String! | The filename of the design |
| `filename` | String! | Filename of the design file | | `fullPath` | String! | The full path to the design file |
| `fullPath` | String! | Full path of the design file | | `image` | String! | The URL of the image |
| `event` | DesignVersionEvent! | Type of change made to the design at the version specified by the `atVersion` argument if supplied. Defaults to the latest version | | `diffRefs` | DiffRefs! | The diff refs for this design |
| `image` | String! | Image of the design | | `event` | DesignVersionEvent! | How this design was changed in the current version |
| `diffRefs` | DiffRefs! | Diff refs of the design | | `notesCount` | Int! | The total count of user-created notes for this design |
### DesignCollection ### DesignCollection
......
# frozen_string_literal: true
module Types
module DesignManagement
class DesignAtVersionType < BaseObject
graphql_name 'DesignAtVersion'
description 'A design pinned to a specific version'
authorize :read_design
delegate :design, :version, to: :object
delegate :issue, :filename, :full_path, :diff_refs, to: :design
implements ::Types::DesignManagement::DesignFields
field :version,
Types::DesignManagement::VersionType,
null: false,
description: 'The version this design-at-versions is pinned to'
field :design,
Types::DesignManagement::DesignType,
null: false,
description: 'The underlying design.'
def cached_stateful_version(_parent)
version
end
def notes_count
design.user_notes_count
end
end
end
end
# frozen_string_literal: true
module Types
module DesignManagement
module DesignFields
include BaseInterface
field_class Types::BaseField
field :id, GraphQL::ID_TYPE, description: 'The ID of this design', null: false
field :project, Types::ProjectType, null: false, description: 'The project the design belongs to'
field :issue, Types::IssueType, null: false, description: 'The issue the design belongs to'
field :filename, GraphQL::STRING_TYPE, null: false, description: 'The filename of the design'
field :full_path, GraphQL::STRING_TYPE, null: false, description: 'The full path to the design file'
field :image, GraphQL::STRING_TYPE, null: false, extras: [:parent], description: 'The URL of the image'
field :diff_refs, Types::DiffRefsType,
null: false,
calls_gitaly: true,
extras: [:parent],
description: 'The diff refs for this design'
field :event, Types::DesignManagement::DesignVersionEventEnum,
null: false,
extras: [:parent],
description: 'How this design was changed in the current version'
field :notes_count,
GraphQL::INT_TYPE,
null: false,
method: :user_notes_count,
description: 'The total count of user-created notes for this design'
def diff_refs(parent:)
version = cached_stateful_version(parent)
version.diff_refs
end
def image(parent:)
sha = cached_stateful_version(parent).sha
::Gitlab::Routing.url_helpers.project_design_url(design.project, design, sha)
end
def event(parent:)
version = cached_stateful_version(parent)
action = cached_actions_for_version(version)[design.id]
action&.event || ::Types::DesignManagement::DesignVersionEventEnum::NONE
end
def cached_actions_for_version(version)
Gitlab::SafeRequestStore.fetch(['DesignFields', 'actions_for_version', version.id]) do
version.actions.to_h { |dv| [dv.design_id, dv] }
end
end
def project
::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Project, design.project_id).find
end
def issue
::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Issue, design.issue_id).find
end
end
end
end
...@@ -7,53 +7,17 @@ module Types ...@@ -7,53 +7,17 @@ module Types
authorize :read_design authorize :read_design
implements(Types::Notes::NoteableType)
alias_method :design, :object alias_method :design, :object
field :id, GraphQL::ID_TYPE, null: false, implements(Types::Notes::NoteableType)
description: 'ID of the design' implements(Types::DesignManagement::DesignFields)
field :project, Types::ProjectType, null: false,
description: 'Project associated with the design'
field :issue, Types::IssueType, null: false,
description: 'Issue associated with the design'
field :notes_count, GraphQL::INT_TYPE, null: false,
method: :user_notes_count,
description: 'Total count of user-created notes for the design'
field :filename, GraphQL::STRING_TYPE, null: false,
description: 'Filename of the design file'
field :full_path, GraphQL::STRING_TYPE, null: false,
description: 'Full path of the design file'
field :event, Types::DesignManagement::DesignVersionEventEnum, null: false,
description: 'Type of change made to the design at the version specified by the `atVersion` argument '\
'if supplied. Defaults to the latest version',
extras: [:parent]
field :image, GraphQL::STRING_TYPE, null: false,
description: 'Image of the design',
extras: [:parent]
field :diff_refs, Types::DiffRefsType, null: false,
description: 'Diff refs of the design',
calls_gitaly: true
field :versions, field :versions,
Types::DesignManagement::VersionType.connection_type, Types::DesignManagement::VersionType.connection_type,
resolver: Resolvers::DesignManagement::VersionResolver, resolver: Resolvers::DesignManagement::VersionResolver,
description: 'All versions related to the design, ordered newest first', description: "All versions related to this design ordered newest first",
extras: [:parent] extras: [:parent]
def image(parent:)
sha = cached_stateful_version(parent).sha
Gitlab::Routing.url_helpers.project_design_url(design.project, design, sha)
end
def event(parent:)
version = cached_stateful_version(parent)
action = cached_actions_for_version(version)[design.id]
action&.event || Types::DesignManagement::DesignVersionEventEnum::NONE
end
# Returns a `DesignManagement::Version` for this query based on the # Returns a `DesignManagement::Version` for this query based on the
# `atVersion` argument passed to a parent node if present, or otherwise # `atVersion` argument passed to a parent node if present, or otherwise
# the most recent `Version` for the issue. # the most recent `Version` for the issue.
...@@ -71,12 +35,6 @@ module Types ...@@ -71,12 +35,6 @@ module Types
end end
end end
def cached_actions_for_version(version)
Gitlab::SafeRequestStore.fetch([request_cache_base_key, 'actions_for_version', version.id]) do
version.actions.to_h { |dv| [dv.design_id, dv] }
end
end
def request_cache_base_key def request_cache_base_key
self.class.name self.class.name
end end
......
...@@ -34,6 +34,14 @@ module DesignManagement ...@@ -34,6 +34,14 @@ module DesignManagement
"#{design.id}.#{version.id}" "#{design.id}.#{version.id}"
end end
def ==(other)
return false unless other && self.class == other.class
other.id == id
end
alias_method :eql?, :==
def self.lazy_find(id) def self.lazy_find(id)
BatchLoader.for(id).batch do |ids, callback| BatchLoader.for(id).batch do |ids, callback|
find(ids).each do |record| find(ids).each do |record|
......
# frozen_string_literal: true
require 'spec_helper'
# describe GitlabSchema.types['DesignAtVersion'] do
# This not available on the schema until we mount it somewhere
describe ::Types::DesignManagement::DesignAtVersionType.to_graphql do
it_behaves_like 'a GraphQL type with design fields' do
let(:extra_design_fields) { %i[version design] }
let_it_be(:design) { create(:design, :with_versions) }
let(:object_id) do
version = design.versions.first
GitlabSchema.id_from_object(create(:design_at_version, design: design, version: version))
end
let_it_be(:object_id_b) { GitlabSchema.id_from_object(create(:design_at_version)) }
let(:object_type) { ::Types::DesignManagement::DesignAtVersionType }
end
end
...@@ -3,26 +3,11 @@ ...@@ -3,26 +3,11 @@
require 'spec_helper' require 'spec_helper'
describe GitlabSchema.types['Design'] do describe GitlabSchema.types['Design'] do
it { expect(described_class).to require_graphql_authorizations(:read_design) } it_behaves_like 'a GraphQL type with design fields' do
let(:extra_design_fields) { %i[notes discussions versions] }
it { expect(described_class.interfaces).to include(Types::Notes::NoteableType.to_graphql) } let_it_be(:design) { create(:design, :with_versions) }
let(:object_id) { GitlabSchema.id_from_object(design) }
it 'exposes the expected fields' do let_it_be(:object_id_b) { GitlabSchema.id_from_object(create(:design, :with_versions)) }
expected_fields = %i[ let(:object_type) { ::Types::DesignManagement::DesignType }
diff_refs
discussions
event
filename
full_path
id
image
issue
notes
notes_count
project
versions
]
is_expected.to have_graphql_fields(*expected_fields)
end end
end end
...@@ -18,6 +18,59 @@ describe DesignManagement::DesignAtVersion do ...@@ -18,6 +18,59 @@ describe DesignManagement::DesignAtVersion do
end end
end end
describe '#==' do
it 'identifies objects created with the same parameters as equal' do
design = build_stubbed(:design, issue: issue)
version = build_stubbed(:design_version, designs: [design], issue: issue)
this = build_stubbed(:design_at_version, design: design, version: version)
other = build_stubbed(:design_at_version, design: design, version: version)
expect(this).to eq(other)
expect(other).to eq(this)
end
it 'identifies unequal objects as unequal, by virtue of their version' do
design = build_stubbed(:design, issue: issue)
version_a = build_stubbed(:design_version, designs: [design])
version_b = build_stubbed(:design_version, designs: [design])
this = build_stubbed(:design_at_version, design: design, version: version_a)
other = build_stubbed(:design_at_version, design: design, version: version_b)
expect(this).not_to eq(nil)
expect(this).not_to eq(design)
expect(this).not_to eq(other)
expect(other).not_to eq(this)
end
it 'identifies unequal objects as unequal, by virtue of their design' do
design_a = build_stubbed(:design, issue: issue)
design_b = build_stubbed(:design, issue: issue)
version = build_stubbed(:design_version, designs: [design_a, design_b])
this = build_stubbed(:design_at_version, design: design_a, version: version)
other = build_stubbed(:design_at_version, design: design_b, version: version)
expect(this).not_to eq(other)
expect(other).not_to eq(this)
end
it 'rejects objects with the same id and the wrong class' do
dav = build_stubbed(:design_at_version)
expect(dav).not_to eq(OpenStruct.new(id: dav.id))
end
it 'expects objects to be of the same type, not subtypes' do
subtype = Class.new(described_class)
dav = build_stubbed(:design_at_version)
other = subtype.new(design: dav.design, version: dav.version)
expect(dav).not_to eq(other)
end
end
describe 'status methods' do describe 'status methods' do
let!(:design_a) { create(:design, issue: issue) } let!(:design_a) { create(:design, issue: issue) }
let!(:design_b) { create(:design, issue: issue) } let!(:design_b) { create(:design, issue: issue) }
......
# frozen_string_literal: true
# To use these shared examples, you may define a value in scope named
# `extra_design_fields`, to pass any extra fields in addition to the
# standard design fields.
shared_examples 'a GraphQL type with design fields' do
let(:extra_design_fields) { [] }
it { expect(described_class).to require_graphql_authorizations(:read_design) }
it 'exposes the expected design fields' do
expected_fields = %i[
id
project
issue
filename
full_path
image
diff_refs
event
notes_count
] + extra_design_fields
is_expected.to have_graphql_fields(*expected_fields).only
end
describe '#image' do
let(:schema) { GitlabSchema }
let(:query) { GraphQL::Query.new(schema) }
let(:context) { double('Context', schema: schema, query: query, parent: nil) }
let(:field) { described_class.fields['image'] }
let(:args) { GraphQL::Query::Arguments::NO_ARGS }
let(:instance) do
object = GitlabSchema.sync_lazy(GitlabSchema.object_from_id(object_id))
object_type.authorized_new(object, query.context)
end
let(:instance_b) do
object_b = GitlabSchema.sync_lazy(GitlabSchema.object_from_id(object_id_b))
object_type.authorized_new(object_b, query.context)
end
it 'resolves to the design image URL' do
image = field.resolve(instance, args, context)
sha = design.versions.first.sha
url = ::Gitlab::Routing.url_helpers.project_design_url(design.project, design, sha)
expect(image).to eq(url)
end
it 'has better than O(N) peformance', :request_store do
# Assuming designs have been loaded (as they must be), the following
# queries are required:
# For each distinct version:
# - design_management_versions
# (Request store is needed so that each version is fetched only once.)
# For each distinct issue
# - issues
# For each distinct project
# - projects
# - routes
# - namespaces
# Here that total is:
# - 2 x issues
# - 2 x versions
# - 2 x (projects + routes + namespaces)
# = 10
expect(instance).not_to eq(instance_b) # preload designs themselves.
expect do
image_a = field.resolve(instance, args, context)
image_b = field.resolve(instance, args, context)
image_c = field.resolve(instance_b, args, context)
image_d = field.resolve(instance_b, args, context)
expect(image_a).to eq(image_b)
expect(image_c).not_to eq(image_b)
expect(image_c).to eq(image_d)
end.not_to exceed_query_limit(10)
end
end
end
...@@ -124,14 +124,26 @@ describe GitlabSchema do ...@@ -124,14 +124,26 @@ describe GitlabSchema do
describe '.object_from_id' do describe '.object_from_id' do
context 'for subclasses of `ApplicationRecord`' do context 'for subclasses of `ApplicationRecord`' do
it 'returns the correct record' do let_it_be(:user) { create(:user) }
user = create(:user)
it 'returns the correct record' do
result = described_class.object_from_id(user.to_global_id.to_s) result = described_class.object_from_id(user.to_global_id.to_s)
expect(result.sync).to eq(user) expect(result.sync).to eq(user)
end end
it 'returns the correct record, of the expected type' do
result = described_class.object_from_id(user.to_global_id.to_s, expected_type: ::User)
expect(result.sync).to eq(user)
end
it 'fails if the type does not match' do
expect do
described_class.object_from_id(user.to_global_id.to_s, expected_type: ::Project)
end.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
end
it 'batchloads the queries' do it 'batchloads the queries' do
user1 = create(:user) user1 = create(:user)
user2 = create(:user) user2 = create(:user)
......
...@@ -7,7 +7,16 @@ describe GitlabSchema.types['Query'] do ...@@ -7,7 +7,16 @@ 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, :namespace, :group, :echo, :metadata, :current_user, :snippets) } it do
is_expected.to have_graphql_fields(:project,
:namespace,
:group,
:echo,
:metadata,
:current_user,
:snippets
).at_least
end
describe 'namespace field' do describe 'namespace field' do
subject { described_class.fields['namespace'] } subject { described_class.fields['namespace'] }
......
...@@ -105,12 +105,15 @@ module GraphqlHelpers ...@@ -105,12 +105,15 @@ module GraphqlHelpers
end end
def query_graphql_field(name, attributes = {}, fields = nil) def query_graphql_field(name, attributes = {}, fields = nil)
fields ||= all_graphql_fields_for(name.classify) field_params = if attributes.present?
attributes = attributes_to_graphql(attributes) "(#{attributes_to_graphql(attributes)})"
attributes = "(#{attributes})" if attributes.present? else
''
end
<<~QUERY <<~QUERY
#{name}#{attributes} #{GraphqlHelpers.fieldnamerize(name.to_s)}#{field_params}
#{wrap_fields(fields)} #{wrap_fields(fields || all_graphql_fields_for(name.to_s.classify))}
QUERY QUERY
end end
...@@ -301,6 +304,17 @@ module GraphqlHelpers ...@@ -301,6 +304,17 @@ module GraphqlHelpers
def global_id_of(model) def global_id_of(model)
model.to_global_id.to_s model.to_global_id.to_s
end end
def missing_required_argument(path, argument)
a_hash_including(
'path' => ['query'].concat(path),
'extensions' => a_hash_including('code' => 'missingRequiredArguments', 'arguments' => argument.to_s)
)
end
def custom_graphql_error(path, msg)
a_hash_including('path' => path, 'message' => msg)
end
end end
# This warms our schema, doing this as part of loading the helpers to avoid # This warms our schema, doing this as part of loading the helpers to avoid
......
...@@ -11,9 +11,23 @@ RSpec::Matchers.define :have_graphql_fields do |*expected| ...@@ -11,9 +11,23 @@ RSpec::Matchers.define :have_graphql_fields do |*expected|
Array.wrap(expected).map { |name| GraphqlHelpers.fieldnamerize(name) } Array.wrap(expected).map { |name| GraphqlHelpers.fieldnamerize(name) }
end end
@allow_extra = false
chain :only do
@allow_extra = false
end
chain :at_least do
@allow_extra = true
end
match do |kls| match do |kls|
if @allow_extra
expect(kls.fields.keys).to include(*expected_field_names)
else
expect(kls.fields.keys).to contain_exactly(*expected_field_names) expect(kls.fields.keys).to contain_exactly(*expected_field_names)
end end
end
failure_message do |kls| failure_message do |kls|
missing = expected_field_names - kls.fields.keys missing = expected_field_names - kls.fields.keys
...@@ -22,7 +36,7 @@ RSpec::Matchers.define :have_graphql_fields do |*expected| ...@@ -22,7 +36,7 @@ RSpec::Matchers.define :have_graphql_fields do |*expected|
message = [] message = []
message << "is missing fields: <#{missing.inspect}>" if missing.any? message << "is missing fields: <#{missing.inspect}>" if missing.any?
message << "contained unexpected fields: <#{extra.inspect}>" if extra.any? message << "contained unexpected fields: <#{extra.inspect}>" if extra.any? && !@allow_extra
message.join("\n") message.join("\n")
end end
......
# frozen_string_literal: true
require 'spec_helper'
# Shared example for legal queries that are expected to return nil.
# Requires the following let bindings to be defined:
# - post_query: action to send the query
# - path: array of keys from query root to the result
shared_examples 'a failure to find anything' do
it 'finds nothing' do
post_query
data = graphql_data.dig(*path)
expect(data).to be_nil
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment