Commit 306bbfb4 authored by Robert Speicher's avatar Robert Speicher

Merge branch...

Merge branch '13495-design-versions-should-not-have-duplicate-ids-and-have-version-included-in-id' into 'master'

13495-e: Create DesignAtVersion

Closes #13495

See merge request gitlab-org/gitlab!15260
parents 1aa19e0d ce3795fd
...@@ -58,5 +58,9 @@ module Resolvers ...@@ -58,5 +58,9 @@ module Resolvers
def single? def single?
false false
end end
def current_user
context[:current_user]
end
end end
end end
...@@ -40,3 +40,5 @@ module Types ...@@ -40,3 +40,5 @@ module Types
resolver: Resolvers::EchoResolver resolver: Resolvers::EchoResolver
end end
end end
Types::QueryType.prepend_if_ee('EE::Types::QueryType')
...@@ -654,6 +654,16 @@ type Design implements DesignFields & Noteable { ...@@ -654,6 +654,16 @@ type Design implements DesignFields & Noteable {
""" """
before: String before: String
"""
The Global ID of the most recent acceptable version
"""
earlierOrEqualToId: ID
"""
The SHA256 of the most recent acceptable version
"""
earlierOrEqualToSha: String
""" """
Returns the first _n_ elements from the list. Returns the first _n_ elements from the list.
""" """
...@@ -666,10 +676,130 @@ type Design implements DesignFields & Noteable { ...@@ -666,10 +676,130 @@ type Design implements DesignFields & Noteable {
): DesignVersionConnection! ): DesignVersionConnection!
} }
"""
A design pinned to a specific version. The image field reflects the design as of the associated version.
"""
type DesignAtVersion implements DesignFields {
"""
The underlying design.
"""
design: Design!
"""
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!
"""
The version this design-at-versions is pinned to
"""
version: DesignVersion!
}
"""
The connection type for DesignAtVersion.
"""
type DesignAtVersionConnection {
"""
A list of edges.
"""
edges: [DesignAtVersionEdge]
"""
A list of nodes.
"""
nodes: [DesignAtVersion]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type DesignAtVersionEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: DesignAtVersion
}
""" """
A collection of designs. A collection of designs.
""" """
type DesignCollection { type DesignCollection {
"""
Find a specific design
"""
design(
"""
Find a design by its filename
"""
filename: String
"""
Find a design by its ID
"""
id: ID
): Design
"""
Find a design as of a version
"""
designAtVersion(
"""
The Global ID of the design at this version
"""
id: ID!
): DesignAtVersion
""" """
All designs for the design collection All designs for the design collection
""" """
...@@ -721,6 +851,21 @@ type DesignCollection { ...@@ -721,6 +851,21 @@ type DesignCollection {
""" """
project: Project! project: Project!
"""
A specific version
"""
version(
"""
The Global ID of the version
"""
id: ID
"""
The SHA256 of a specific version
"""
sha: String
): DesignVersion
""" """
All versions related to all designs, ordered newest first All versions related to all designs, ordered newest first
""" """
...@@ -735,6 +880,16 @@ type DesignCollection { ...@@ -735,6 +880,16 @@ type DesignCollection {
""" """
before: String before: String
"""
The Global ID of the most recent acceptable version
"""
earlierOrEqualToId: ID
"""
The SHA256 of the most recent acceptable version
"""
earlierOrEqualToSha: String
""" """
Returns the first _n_ elements from the list. Returns the first _n_ elements from the list.
""" """
...@@ -829,6 +984,28 @@ interface DesignFields { ...@@ -829,6 +984,28 @@ interface DesignFields {
project: Project! project: Project!
} }
type DesignManagement {
"""
Find a design as of a version
"""
designAtVersion(
"""
The Global ID of the design at this version
"""
id: ID!
): DesignAtVersion
"""
Find a version
"""
version(
"""
The Global ID of the version
"""
id: ID!
): DesignVersion
}
""" """
Autogenerated input type of DesignManagementDelete Autogenerated input type of DesignManagementDelete
""" """
...@@ -924,7 +1101,30 @@ type DesignManagementUploadPayload { ...@@ -924,7 +1101,30 @@ type DesignManagementUploadPayload {
skippedDesigns: [Design!]! skippedDesigns: [Design!]!
} }
"""
A specific version in which designs were added, modified or deleted
"""
type DesignVersion { type DesignVersion {
"""
A particular design as of this version, provided it is visible at this version
"""
designAtVersion(
"""
The ID of a specific design
"""
designId: ID
"""
The filename of a specific design
"""
filename: String
"""
The ID of the DesignAtVersion
"""
id: ID
): DesignAtVersion!
""" """
All designs that were changed in the version All designs that were changed in the version
""" """
...@@ -950,6 +1150,41 @@ type DesignVersion { ...@@ -950,6 +1150,41 @@ type DesignVersion {
last: Int last: Int
): DesignConnection! ): DesignConnection!
"""
All designs that are visible at this version, as of this version
"""
designsAtVersion(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Filters designs by their filename
"""
filenames: [String!]
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Filters designs by their ID
"""
ids: [ID!]
"""
Returns the last _n_ elements from the list.
"""
last: Int
): DesignAtVersionConnection!
""" """
ID of the design version ID of the design version
""" """
...@@ -5604,6 +5839,11 @@ type Query { ...@@ -5604,6 +5839,11 @@ type Query {
""" """
currentUser: User currentUser: User
"""
Fields related to design management
"""
designManagement: DesignManagement!
""" """
Text to echo back Text to echo back
""" """
......
...@@ -130,6 +130,24 @@ A single design ...@@ -130,6 +130,24 @@ A single design
| `event` | DesignVersionEvent! | How this design was changed in the current version | | `event` | DesignVersionEvent! | How this design was changed in the current version |
| `notesCount` | Int! | The total count of user-created notes for this design | | `notesCount` | Int! | The total count of user-created notes for this design |
## DesignAtVersion
A design pinned to a specific version. The image field reflects the design as of the associated version.
| Name | Type | Description |
| --- | ---- | ---------- |
| `id` | ID! | The ID of this design |
| `project` | Project! | The project the design belongs to |
| `issue` | Issue! | The issue the design belongs to |
| `filename` | String! | The filename of the design |
| `fullPath` | String! | The full path to the design file |
| `image` | String! | The URL of the image |
| `diffRefs` | DiffRefs! | The diff refs for this design |
| `event` | DesignVersionEvent! | How this design was changed in the current version |
| `notesCount` | Int! | The total count of user-created notes for this design |
| `version` | DesignVersion! | The version this design-at-versions is pinned to |
| `design` | Design! | The underlying design. |
## DesignCollection ## DesignCollection
A collection of designs. A collection of designs.
...@@ -138,6 +156,16 @@ A collection of designs. ...@@ -138,6 +156,16 @@ A collection of designs.
| --- | ---- | ---------- | | --- | ---- | ---------- |
| `project` | Project! | Project associated with the design collection | | `project` | Project! | Project associated with the design collection |
| `issue` | Issue! | Issue associated with the design collection | | `issue` | Issue! | Issue associated with the design collection |
| `version` | DesignVersion | A specific version |
| `designAtVersion` | DesignAtVersion | Find a design as of a version |
| `design` | Design | Find a specific design |
## DesignManagement
| Name | Type | Description |
| --- | ---- | ---------- |
| `version` | DesignVersion | Find a version |
| `designAtVersion` | DesignAtVersion | Find a design as of a version |
## DesignManagementDeletePayload ## DesignManagementDeletePayload
...@@ -162,10 +190,13 @@ Autogenerated return type of DesignManagementUpload ...@@ -162,10 +190,13 @@ Autogenerated return type of DesignManagementUpload
## DesignVersion ## DesignVersion
A specific version in which designs were added, modified or deleted
| Name | Type | Description | | Name | Type | Description |
| --- | ---- | ---------- | | --- | ---- | ---------- |
| `id` | ID! | ID of the design version | | `id` | ID! | ID of the design version |
| `sha` | ID! | SHA of the design version | | `sha` | ID! | SHA of the design version |
| `designAtVersion` | DesignAtVersion! | A particular design as of this version, provided it is visible at this version |
## DestroyNotePayload ## DestroyNotePayload
......
...@@ -6,7 +6,9 @@ module DesignManagement ...@@ -6,7 +6,9 @@ module DesignManagement
# Params: # Params:
# ids: integer[] # ids: integer[]
# filenames: string[]
# visible_at_version: ?version # visible_at_version: ?version
# filenames: String[]
def initialize(issue, current_user, params = {}) def initialize(issue, current_user, params = {})
@issue = issue @issue = issue
@current_user = current_user @current_user = current_user
...@@ -39,13 +41,15 @@ module DesignManagement ...@@ -39,13 +41,15 @@ module DesignManagement
end end
def by_filename(items) def by_filename(items)
return items unless params[:filenames].present? return items if params[:filenames].nil?
return ::DesignManagement::Design.none if params[:filenames].empty?
items.with_filename(params[:filenames]) items.with_filename(params[:filenames])
end end
def by_id(items) def by_id(items)
return items unless params[:ids].present? return items if params[:ids].nil?
return ::DesignManagement::Design.none if params[:ids].empty?
items.id_in(params[:ids]) items.id_in(params[:ids])
end end
......
# frozen_string_literal: true
module EE
module Types
module QueryType
extend ActiveSupport::Concern
# The design management context object needs to implement #issue
DesignManagementObject = Struct.new(:issue)
prepended do
field :design_management, ::Types::DesignManagementType,
null: false,
description: 'Fields related to design management'
def design_management
DesignManagementObject.new(nil)
end
end
end
end
end
# frozen_string_literal: true
module Resolvers
module DesignManagement
class DesignAtVersionResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
type Types::DesignManagement::DesignAtVersionType, null: false
authorize :read_design
argument :id, GraphQL::ID_TYPE,
required: true,
description: 'The Global ID of the design at this version'
def resolve(id:)
authorized_find!(id: id)
end
def find_object(id:)
dav = GitlabSchema.object_from_id(id, expected_type: ::DesignManagement::DesignAtVersion)
return unless consistent?(dav)
dav
end
def self.single
self
end
private
# If this resolver is mounted on something that has an issue
# (such as design collection for instance), then we should check
# that the DesignAtVersion as found by its ID does in fact belong
# to this issue.
def consistent?(dav)
issue.nil? || (dav&.design&.issue_id == issue.id)
end
def issue
object&.issue
end
end
end
end
...@@ -3,40 +3,54 @@ ...@@ -3,40 +3,54 @@
module Resolvers module Resolvers
module DesignManagement module DesignManagement
class DesignResolver < BaseResolver class DesignResolver < BaseResolver
argument :ids, argument :id, GraphQL::ID_TYPE,
[GraphQL::ID_TYPE],
required: false, required: false,
description: 'Filters designs by their ID' description: 'Find a design by its ID'
argument :filenames,
[GraphQL::STRING_TYPE], argument :filename, GraphQL::STRING_TYPE,
required: false,
description: 'Filters designs by their filename'
argument :at_version,
GraphQL::ID_TYPE,
required: false, required: false,
description: 'Filters designs to only those that existed at the version. ' \ description: 'Find a design by its filename'
'If argument is omitted or nil then all designs will reflect the latest version'
def resolve(filename: nil, id: nil)
params = parse_args(filename, id)
def resolve(**args) build_finder(params).execute.first
find_designs(args)
end end
def version(args) def self.single
args[:at_version] ? GitlabSchema.object_from_id(args[:at_version])&.sync : nil self
end
private
def issue
object.issue
end end
def design_ids(args) def build_finder(params)
args[:ids] ? args[:ids].map { |id| GlobalID.parse(id).model_id } : nil ::DesignManagement::DesignsFinder.new(issue, current_user, params)
end
def error(msg)
raise ::Gitlab::Graphql::Errors::ArgumentError, msg
end
def parse_args(filename, id)
provided = [filename, id].map(&:present?)
if provided.none?
error('one of id or filename must be passed')
elsif provided.all?
error('only one of id or filename may be passed')
elsif filename.present?
{ filenames: [filename] }
else
{ ids: [parse_gid(id)] }
end
end end
def find_designs(args) def parse_gid(gid)
::DesignManagement::DesignsFinder.new( GitlabSchema.parse_gid(gid, expected_type: ::DesignManagement::Design).model_id
object.issue,
context[:current_user],
ids: design_ids(args),
filenames: args[:filenames],
visible_at_version: version(args)
).execute
end end
end end
end end
......
# frozen_string_literal: true
module Resolvers
module DesignManagement
class DesignsResolver < BaseResolver
argument :ids,
[GraphQL::ID_TYPE],
required: false,
description: 'Filters designs by their ID'
argument :filenames,
[GraphQL::STRING_TYPE],
required: false,
description: 'Filters designs by their filename'
argument :at_version,
GraphQL::ID_TYPE,
required: false,
description: 'Filters designs to only those that existed at the version. ' \
'If argument is omitted or nil then all designs will reflect the latest version'
def self.single
::Resolvers::DesignManagement::DesignResolver
end
def resolve(ids: nil, filenames: nil, at_version: nil)
::DesignManagement::DesignsFinder.new(
issue,
current_user,
ids: design_ids(ids),
filenames: filenames,
visible_at_version: version(at_version),
order: :id
).execute
end
private
def version(at_version)
GitlabSchema.object_from_id(at_version)&.sync if at_version
end
def design_ids(ids)
ids&.map { |id| GlobalID.parse(id).model_id }
end
def issue
object.issue
end
end
end
end
# frozen_string_literal: true
module Resolvers
module DesignManagement
module Version
# Resolver for a DesignAtVersion object given an implicit version context
class DesignAtVersionResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
type Types::DesignManagement::DesignAtVersionType, null: true
authorize :read_design
argument :id, GraphQL::ID_TYPE,
required: false,
as: :design_at_version_id,
description: 'The ID of the DesignAtVersion'
argument :design_id, GraphQL::ID_TYPE,
required: false,
description: 'The ID of a specific design'
argument :filename, GraphQL::STRING_TYPE,
required: false,
description: 'The filename of a specific design'
def self.single
self
end
def resolve(design_id: nil, filename: nil, design_at_version_id: nil)
validate_arguments(design_id, filename, design_at_version_id)
return unless Ability.allowed?(current_user, :read_design, issue)
return specific_design_at_version(design_at_version_id) if design_at_version_id
find(design_id, filename).map { |d| make(d) }.first
end
private
def validate_arguments(design_id, filename, design_at_version_id)
args = { filename: filename, id: design_at_version_id, design_id: design_id }
passed = args.compact.keys
return if passed.size == 1
msg = "Exactly one of #{args.keys.join(', ')} expected, got #{passed}"
raise Gitlab::Graphql::Errors::ArgumentError, msg
end
def specific_design_at_version(id)
dav = GitlabSchema.object_from_id(id, expected_type: ::DesignManagement::DesignAtVersion)
return unless consistent?(dav)
dav
end
# Test that the DAV found by ID actually belongs on this version, and
# that it is visible at this version.
def consistent?(dav)
return false unless dav.present?
dav.design.issue_id == issue.id &&
dav.version.id == version.id &&
dav.design.visible_in?(version)
end
def find(id, filename)
ids = [parse_design_id(id).model_id] if id
filenames = [filename] if filename
::DesignManagement::DesignsFinder
.new(issue, current_user, ids: ids, filenames: filenames, visible_at_version: version)
.execute
end
def parse_design_id(id)
GitlabSchema.parse_gid(id, expected_type: ::DesignManagement::Design)
end
def issue
version.issue
end
def version
object
end
def make(design)
::DesignManagement::DesignAtVersion.new(design: design, version: version)
end
end
end
end
end
# frozen_string_literal: true
module Resolvers
module DesignManagement
module Version
# Resolver for DesignAtVersion objects given an implicit version context
class DesignsAtVersionResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
type Types::DesignManagement::DesignAtVersionType, null: true
authorize :read_design
argument :ids,
[GraphQL::ID_TYPE],
required: false,
description: 'Filters designs by their ID'
argument :filenames,
[GraphQL::STRING_TYPE],
required: false,
description: 'Filters designs by their filename'
def self.single
::Resolvers::DesignManagement::Version::DesignAtVersionResolver
end
def resolve(ids: nil, filenames: nil)
find(ids, filenames).execute.map { |d| make(d) }
end
private
def find(ids, filenames)
ids = ids&.map { |id| parse_design_id(id).model_id }
::DesignManagement::DesignsFinder.new(issue, current_user,
ids: ids,
filenames: filenames,
visible_at_version: version)
end
def parse_design_id(id)
GitlabSchema.parse_gid(id, expected_type: ::DesignManagement::Design)
end
def issue
version.issue
end
def version
object
end
def make(design)
::DesignManagement::DesignAtVersion.new(design: design, version: version)
end
end
end
end
end
# frozen_string_literal: true
module Resolvers
module DesignManagement
class VersionInCollectionResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
type Types::DesignManagement::VersionType, null: true
authorize :read_design
alias_method :collection, :object
argument :sha, GraphQL::STRING_TYPE,
required: false,
description: "The SHA256 of a specific version"
argument :id, GraphQL::ID_TYPE,
required: false,
description: 'The Global ID of the version'
def resolve(id: nil, sha: nil)
check_args(id, sha)
gid = GitlabSchema.parse_gid(id, expected_type: ::DesignManagement::Version) if id
::DesignManagement::VersionsFinder
.new(collection, current_user, sha: sha, version_id: gid&.model_id)
.execute
.first
end
def self.single
self
end
private
def check_args(id, sha)
return if id.present? || sha.present?
raise ::Gitlab::Graphql::Errors::ArgumentError, 'one of id or sha is required'
end
end
end
end
...@@ -3,25 +3,22 @@ ...@@ -3,25 +3,22 @@
module Resolvers module Resolvers
module DesignManagement module DesignManagement
class VersionResolver < BaseResolver class VersionResolver < BaseResolver
type Types::DesignManagement::VersionType.connection_type, null: false include Gitlab::Graphql::Authorize::AuthorizeResource
alias_method :design_or_collection, :object type Types::DesignManagement::VersionType, null: true
def resolve(parent: nil) authorize :read_design
# Find an `at_version` argument passed to a parent node.
#
# If one is found, then a design collection further up the AST
# has been filtered to reflect designs at that version, and so
# for consistency we should only present versions up to the given
# version here.
at_version = Gitlab::Graphql::FindArgumentInParent.find(parent, :at_version, limit_depth: 4)
version = at_version ? GitlabSchema.object_from_id(at_version).sync : nil
::DesignManagement::VersionsFinder.new( argument :id, GraphQL::ID_TYPE,
design_or_collection, required: true,
context[:current_user], description: 'The Global ID of the version'
earlier_or_equal_to: version
).execute def resolve(id:)
authorized_find!(id: id)
end
def find_object(id:)
GitlabSchema.object_from_id(id, expected_type: ::DesignManagement::Version)
end end
end end
end end
......
# frozen_string_literal: true
module Resolvers
module DesignManagement
class VersionsResolver < BaseResolver
type Types::DesignManagement::VersionType.connection_type, null: false
alias_method :design_or_collection, :object
argument :earlier_or_equal_to_sha, GraphQL::STRING_TYPE,
as: :sha,
required: false,
description: 'The SHA256 of the most recent acceptable version'
argument :earlier_or_equal_to_id, GraphQL::ID_TYPE,
as: :id,
required: false,
description: 'The Global ID of the most recent acceptable version'
# This resolver has a custom singular resolver
def self.single
::Resolvers::DesignManagement::VersionInCollectionResolver
end
def resolve(parent: nil, id: nil, sha: nil)
version = cutoff(parent, id, sha)
raise ::Gitlab::Graphql::Errors::ResourceNotAvailable, 'cutoff not found' unless version.present?
if version == :unconstrained
find
else
find(earlier_or_equal_to: version)
end
end
private
# Find the most recent version that the client will accept
def cutoff(parent, id, sha)
if sha.present? || id.present?
specific_version(id, sha)
elsif at_version = at_version_arg(parent)
by_id(at_version)
else
:unconstrained
end
end
def specific_version(id, sha)
gid = GitlabSchema.parse_gid(id, expected_type: ::DesignManagement::Version) if id
find(sha: sha, version_id: gid&.model_id).first
end
def find(**params)
::DesignManagement::VersionsFinder
.new(design_or_collection, current_user, params)
.execute
end
def by_id(id)
GitlabSchema.object_from_id(id, expected_type: ::DesignManagement::Version).sync
end
# Find an `at_version` argument passed to a parent node.
#
# If one is found, then a design collection further up the AST
# has been filtered to reflect designs at that version, and so
# for consistency we should only present versions up to the given
# version here.
def at_version_arg(parent)
::Gitlab::Graphql::FindArgumentInParent.find(parent, :at_version, limit_depth: 4)
end
end
end
end
...@@ -5,7 +5,8 @@ module Types ...@@ -5,7 +5,8 @@ module Types
class DesignAtVersionType < BaseObject class DesignAtVersionType < BaseObject
graphql_name 'DesignAtVersion' graphql_name 'DesignAtVersion'
description 'A design pinned to a specific version' description 'A design pinned to a specific version. ' \
'The image field reflects the design as of the associated version.'
authorize :read_design authorize :read_design
......
...@@ -12,14 +12,32 @@ module Types ...@@ -12,14 +12,32 @@ module Types
description: 'Project associated with the design collection' description: 'Project associated with the design collection'
field :issue, Types::IssueType, null: false, field :issue, Types::IssueType, null: false,
description: 'Issue associated with the design collection' description: 'Issue associated with the design collection'
field :designs, Types::DesignManagement::DesignType.connection_type, null: false,
resolver: Resolvers::DesignManagement::DesignResolver, field :designs,
Types::DesignManagement::DesignType.connection_type,
null: false,
resolver: Resolvers::DesignManagement::DesignsResolver,
description: 'All designs for the design collection' description: 'All designs for the design collection'
# TODO: allow getting a single design by filename
# exposing all designs field :versions,
field :versions, Types::DesignManagement::VersionType.connection_type, Types::DesignManagement::VersionType.connection_type,
resolver: Resolvers::DesignManagement::VersionResolver, resolver: Resolvers::DesignManagement::VersionsResolver,
description: 'All versions related to all designs, ordered newest first' description: 'All versions related to all designs, ordered newest first'
field :version,
Types::DesignManagement::VersionType,
resolver: Resolvers::DesignManagement::VersionsResolver.single,
description: 'A specific version'
field :design_at_version, ::Types::DesignManagement::DesignAtVersionType,
null: true,
resolver: ::Resolvers::DesignManagement::DesignAtVersionResolver,
description: 'Find a design as of a version'
field :design, ::Types::DesignManagement::DesignType,
null: true,
resolver: ::Resolvers::DesignManagement::DesignResolver,
description: 'Find a specific design'
end end
end end
end end
...@@ -15,7 +15,7 @@ module Types ...@@ -15,7 +15,7 @@ module Types
field :versions, field :versions,
Types::DesignManagement::VersionType.connection_type, Types::DesignManagement::VersionType.connection_type,
resolver: Resolvers::DesignManagement::VersionResolver, resolver: Resolvers::DesignManagement::VersionsResolver,
description: "All versions related to this design ordered newest first", description: "All versions related to this design ordered newest first",
extras: [:parent] extras: [:parent]
......
...@@ -2,19 +2,36 @@ ...@@ -2,19 +2,36 @@
module Types module Types
module DesignManagement module DesignManagement
class VersionType < BaseObject class VersionType < ::Types::BaseObject
# Just `Version` might be a bit to general to expose globally so adding # Just `Version` might be a bit to general to expose globally so adding
# a `Design` prefix to specify the class exposed in GraphQL # a `Design` prefix to specify the class exposed in GraphQL
graphql_name 'DesignVersion' graphql_name 'DesignVersion'
description 'A specific version in which designs were added, modified or deleted'
authorize :read_design authorize :read_design
field :id, GraphQL::ID_TYPE, null: false, field :id, GraphQL::ID_TYPE, null: false,
description: 'ID of the design version' description: 'ID of the design version'
field :sha, GraphQL::ID_TYPE, null: false, field :sha, GraphQL::ID_TYPE, null: false,
description: 'SHA of the design version' description: 'SHA of the design version'
field :designs, Types::DesignManagement::DesignType.connection_type, null: false,
field :designs,
::Types::DesignManagement::DesignType.connection_type,
null: false,
description: 'All designs that were changed in the version' description: 'All designs that were changed in the version'
field :designs_at_version,
::Types::DesignManagement::DesignAtVersionType.connection_type,
null: false,
description: 'All designs that are visible at this version, as of this version',
resolver: ::Resolvers::DesignManagement::Version::DesignsAtVersionResolver
field :design_at_version,
::Types::DesignManagement::DesignAtVersionType,
null: false,
description: 'A particular design as of this version, provided it is visible at this version',
resolver: ::Resolvers::DesignManagement::Version::DesignsAtVersionResolver.single
end end
end end
end end
# frozen_string_literal: true
# rubocop: disable Graphql/AuthorizeTypes
module Types
class DesignManagementType < BaseObject
graphql_name 'DesignManagement'
field :version, ::Types::DesignManagement::VersionType,
null: true,
resolver: ::Resolvers::DesignManagement::VersionResolver,
description: 'Find a version'
field :design_at_version, ::Types::DesignManagement::DesignAtVersionType,
null: true,
resolver: ::Resolvers::DesignManagement::DesignAtVersionResolver,
description: 'Find a design as of a version'
end
end
...@@ -78,6 +78,19 @@ module DesignManagement ...@@ -78,6 +78,19 @@ module DesignManagement
most_recent_action&.deletion? most_recent_action&.deletion?
end end
# A design is visible_in? a version if:
# * it was created before that version
# * the most recent action before the version was not a deletion
def visible_in?(version)
map = strong_memoize(:visible_in) do
Hash.new do |h, k|
h[k] = self.class.visible_at_version(k).where(id: id).exists?
end
end
map[version]
end
def most_recent_action def most_recent_action
strong_memoize(:most_recent_action) { actions.ordered.last } strong_memoize(:most_recent_action) { actions.ordered.last }
end end
...@@ -181,7 +194,7 @@ module DesignManagement ...@@ -181,7 +194,7 @@ module DesignManagement
def clear_version_cache def clear_version_cache
[versions, actions].each(&:reset) [versions, actions].each(&:reset)
[:new_design, :diff_refs, :head_sha, :most_recent_action].each do |key| %i[new_design diff_refs head_sha visible_in most_recent_action].each do |key|
clear_memoization(key) clear_memoization(key)
end end
end end
......
---
title: 'Create DesignAtVersion model, exposing it with GraphQL'
merge_request: 15260
author:
type: added
...@@ -54,6 +54,20 @@ describe DesignManagement::DesignsFinder do ...@@ -54,6 +54,20 @@ describe DesignManagement::DesignsFinder do
it { is_expected.to eq([design2]) } it { is_expected.to eq([design2]) }
end end
context 'when passed empty array' do
context 'for filenames' do
let(:params) { { filenames: [] } }
it { is_expected.to be_empty }
end
context "for ids" do
let(:params) { { ids: [] } }
it { is_expected.to be_empty }
end
end
describe 'returning designs that existed at a particular given version' do describe 'returning designs that existed at a particular given version' do
let(:all_versions) { issue.design_collection.versions.ordered } let(:all_versions) { issue.design_collection.versions.ordered }
let(:first_version) { all_versions.last } let(:first_version) { all_versions.last }
......
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::DesignManagement::DesignAtVersionResolver do
include GraphqlHelpers
include DesignManagementTestHelpers
let_it_be(:issue) { create(:issue) }
let_it_be(:project) { issue.project }
let_it_be(:user) { create(:user) }
let_it_be(:design_a) { create(:design, issue: issue) }
let_it_be(:version_a) { create(:design_version, issue: issue, created_designs: [design_a]) }
let(:current_user) { user }
let(:object) { issue.design_collection }
let(:global_id) { GitlabSchema.id_from_object(design_at_version).to_s }
let(:design_at_version) { ::DesignManagement::DesignAtVersion.new(design: design_a, version: version_a) }
let(:resource_not_available) { ::Gitlab::Graphql::Errors::ResourceNotAvailable }
before do
enable_design_management
project.add_developer(user)
end
describe '#resolve' do
context 'when the user cannot see designs' do
let(:current_user) { create(:user) }
it 'raises ResourceNotAvailable' do
expect { resolve_design }.to raise_error(resource_not_available)
end
end
it 'returns the specified design' do
expect(resolve_design).to eq(design_at_version)
end
context 'the ID belongs to a design on another issue' do
let(:other_dav) do
create(:design_at_version, issue: create(:issue, project: project))
end
let(:global_id) { global_id_of(other_dav) }
it 'raises ResourceNotAvailable' do
expect { resolve_design }.to raise_error(resource_not_available)
end
context 'the current object does not constrain the issue' do
let(:object) { nil }
it 'returns the object' do
expect(resolve_design).to eq(other_dav)
end
end
end
end
private
def resolve_design
args = { id: global_id }
ctx = { current_user: current_user }
eager_resolve(described_class, obj: object, args: args, ctx: ctx)
end
end
# frozen_string_literal: true # frozen_string_literal: true
require "spec_helper" require 'spec_helper'
describe Resolvers::DesignManagement::DesignResolver do describe Resolvers::DesignManagement::DesignResolver do
include GraphqlHelpers include GraphqlHelpers
...@@ -10,38 +10,79 @@ describe Resolvers::DesignManagement::DesignResolver do ...@@ -10,38 +10,79 @@ describe Resolvers::DesignManagement::DesignResolver do
enable_design_management enable_design_management
end end
describe "#resolve" do describe '#resolve' do
let_it_be(:issue) { create(:issue) } let_it_be(:issue) { create(:issue) }
let_it_be(:project) { issue.project } let_it_be(:project) { issue.project }
let_it_be(:first_version) { create(:design_version) } let_it_be(:first_version) { create(:design_version) }
let_it_be(:first_design) { create(:design, issue: issue, versions: [first_version]) } let_it_be(:first_design) { create(:design, issue: issue, versions: [first_version]) }
let_it_be(:current_user) { create(:user) } let_it_be(:current_user) { create(:user) }
let_it_be(:design_on_other_issue) do
create(:design, issue: create(:issue, project: project), versions: [create(:design_version)])
end
let(:args) { { id: GitlabSchema.id_from_object(first_design).to_s } }
let(:gql_context) { { current_user: current_user } }
before do before do
project.add_developer(current_user) project.add_developer(current_user)
end end
context "when the user cannot see designs" do context 'when the user cannot see designs' do
it "returns nothing" do let(:gql_context) { { current_user: create(:user) } }
expect(resolve_designs(issue.design_collection, {}, current_user: create(:user))).to be_empty
it 'returns nothing' do
expect(resolve_design).to be_nil
end
end
context 'when no argument has been passed' do
let(:args) { {} }
it 'raises an error' do
expect { resolve_design }.to raise_error(::Gitlab::Graphql::Errors::ArgumentError, /must/)
end
end
context 'when both arguments have been passed' do
let(:args) { { filename: first_design.filename, id: GitlabSchema.id_from_object(first_design).to_s } }
it 'raises an error' do
expect { resolve_design }.to raise_error(::Gitlab::Graphql::Errors::ArgumentError, /may/)
end end
end end
context "for a design collection" do context 'by ID' do
it "returns designs" do it 'returns the specified design' do
expect(resolve_designs(issue.design_collection, {}, current_user: current_user)).to contain_exactly(first_design) expect(resolve_design).to eq(first_design)
end
context 'the ID belongs to a design on another issue' do
let(:args) { { id: GitlabSchema.id_from_object(design_on_other_issue).to_s } }
it 'returns nothing' do
expect(resolve_design).to be_nil
end
end
end
context 'by filename' do
let(:args) { { filename: first_design.filename } }
it 'returns the specified design' do
expect(resolve_design).to eq(first_design)
end end
it "returns all designs" do context 'the filename belongs to a design on another issue' do
second_version = create(:design_version) let(:args) { { filename: design_on_other_issue.filename } }
second_design = create(:design, issue: issue, versions: [second_version])
expect(resolve_designs(issue.design_collection, {}, current_user: current_user)).to contain_exactly(first_design, second_design) it 'returns nothing' do
expect(resolve_design).to be_nil
end
end end
end end
end end
def resolve_designs(obj, args = {}, context = { current_user: current_user }) def resolve_design
resolve(described_class, obj: obj, args: args, ctx: context) resolve(described_class, obj: issue.design_collection, args: args, ctx: gql_context)
end end
end end
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::DesignManagement::DesignsResolver do
include GraphqlHelpers
include DesignManagementTestHelpers
before do
enable_design_management
end
describe '#resolve' do
set(:issue) { create(:issue) }
set(:project) { issue.project }
set(:first_version) { create(:design_version) }
set(:first_design) { create(:design, issue: issue, versions: [first_version]) }
set(:current_user) { create(:user) }
let(:gql_context) { { current_user: current_user } }
let(:args) { {} }
before do
project.add_developer(current_user)
end
context 'when the user cannot see designs' do
let(:gql_context) { { current_user: create(:user) } }
it 'returns nothing' do
expect(resolve_designs).to be_empty
end
end
context 'for a design collection' do
context 'which contains just a single design' do
it 'returns just that design' do
expect(resolve_designs).to contain_exactly(first_design)
end
end
context 'which contains another design' do
it 'returns all designs' do
second_version = create(:design_version)
second_design = create(:design, issue: issue, versions: [second_version])
expect(resolve_designs).to contain_exactly(first_design, second_design)
end
end
end
describe 'filtering' do
describe 'by filename' do
let(:second_version) { create(:design_version) }
let(:second_design) { create(:design, issue: issue, versions: [second_version]) }
let(:args) { { filenames: [second_design.filename] } }
it 'resolves to just the relevant design, ignoring designs with the same filename on different issues' do
create(:design, issue: create(:issue, project: project), filename: second_design.filename)
expect(resolve_designs).to contain_exactly(second_design)
end
end
describe 'by id' do
let(:second_version) { create(:design_version) }
let(:second_design) { create(:design, issue: issue, versions: [second_version]) }
context 'the ID is on the current issue' do
let(:args) { { ids: [GitlabSchema.id_from_object(second_design).to_s] } }
it 'resolves to just the relevant design' do
expect(resolve_designs).to contain_exactly(second_design)
end
end
context 'the ID is on a different issue' do
let(:third_version) { create(:design_version) }
let(:third_design) { create(:design, issue: create(:issue, project: project), versions: [third_version]) }
let(:args) { { ids: [GitlabSchema.id_from_object(third_design).to_s] } }
it 'ignores it' do
expect(resolve_designs).to be_empty
end
end
end
end
end
def resolve_designs
resolve(described_class, obj: issue.design_collection, args: args, ctx: gql_context)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::DesignManagement::Version::DesignAtVersionResolver do
include GraphqlHelpers
include_context 'four designs in three versions'
let(:current_user) { authorized_user }
let(:gql_context) { { current_user: current_user } }
let(:version) { third_version }
let(:design) { design_a }
let(:all_singular_args) do
{
design_at_version_id: global_id_of(dav(design)),
design_id: global_id_of(design),
filename: design.filename
}
end
shared_examples 'a bad argument' do
let(:err_class) { ::Gitlab::Graphql::Errors::ArgumentError }
it 'raises an appropriate error' do
expect { resolve_objects }.to raise_error(err_class)
end
end
describe '#resolve' do
describe 'passing combinations of arguments' do
context 'passing no arguments' do
let(:args) { {} }
it_behaves_like 'a bad argument'
end
context 'passing all arguments' do
let(:args) { all_singular_args }
it_behaves_like 'a bad argument'
end
context 'passing any two arguments' do
let(:args) { all_singular_args.slice(*all_singular_args.keys.sample(2)) }
it_behaves_like 'a bad argument'
end
end
%i[design_at_version_id design_id filename].each do |arg|
describe "passing #{arg}" do
let(:args) { all_singular_args.slice(arg) }
it 'finds the design' do
expect(resolve_objects).to eq(dav(design))
end
context 'when the user cannot see designs' do
let(:current_user) { create(:user) }
it 'returns nothing' do
expect(resolve_objects).to be_nil
end
end
end
end
describe 'attempting to retrieve an object not visible at this version' do
let(:design) { design_d }
%i[design_at_version_id design_id filename].each do |arg|
describe "passing #{arg}" do
let(:args) { all_singular_args.slice(arg) }
it 'does not find the design' do
expect(resolve_objects).to be_nil
end
end
end
end
end
def resolve_objects
resolve(described_class, obj: version, args: args, ctx: gql_context)
end
def dav(design)
build(:design_at_version, design: design, version: version)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::DesignManagement::Version::DesignsAtVersionResolver do
include GraphqlHelpers
include_context 'four designs in three versions'
set(:current_user) { authorized_user }
let(:gql_context) { { current_user: current_user } }
let(:version) { third_version }
describe '.single' do
let(:single) { ::Resolvers::DesignManagement::Version::DesignAtVersionResolver }
it 'returns the single context resolver' do
expect(described_class.single).to eq(single)
end
end
describe '#resolve' do
let(:args) { {} }
context 'when the user cannot see designs' do
let(:current_user) { create(:user) }
it 'returns nothing' do
expect(resolve_objects).to be_empty
end
end
context 'for the current version' do
it 'returns all designs visible at that version' do
expect(resolve_objects).to contain_exactly(dav(design_a), dav(design_b), dav(design_c))
end
end
context 'for a previous version with more objects' do
let(:version) { second_version }
it 'returns objects that were later deleted' do
expect(resolve_objects).to contain_exactly(dav(design_a), dav(design_b), dav(design_c), dav(design_d))
end
end
context 'for a previous version with fewer objects' do
let(:version) { first_version }
it 'does not return objects that were later created' do
expect(resolve_objects).to contain_exactly(dav(design_a))
end
end
describe 'filtering' do
describe 'by filename' do
let(:red_herring) { create(:design, issue: create(:issue, project: project)) }
let(:args) { { filenames: [design_b.filename, red_herring.filename] } }
it 'resolves to just the relevant design' do
create(:design, issue: create(:issue, project: project), filename: design_b.filename)
expect(resolve_objects).to contain_exactly(dav(design_b))
end
end
describe 'by id' do
let(:red_herring) { create(:design, issue: create(:issue, project: project)) }
let(:args) { { ids: [design_a, red_herring].map { |x| global_id_of(x) } } }
it 'resolves to just the relevant design, ignoring objects on other issues' do
expect(resolve_objects).to contain_exactly(dav(design_a))
end
end
end
end
def resolve_objects
resolve(described_class, obj: version, args: args, ctx: gql_context)
end
def dav(design)
build(:design_at_version, design: design, version: version)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::DesignManagement::VersionInCollectionResolver do
include GraphqlHelpers
include DesignManagementTestHelpers
let(:resolver) { described_class }
describe '#resolve' do
let_it_be(:issue) { create(:issue) }
let_it_be(:current_user) { create(:user) }
let_it_be(:first_version) { create(:design_version, issue: issue) }
let(:project) { issue.project }
let(:params) { {} }
before do
enable_design_management
project.add_developer(current_user)
end
let(:appropriate_error) { ::Gitlab::Graphql::Errors::ArgumentError }
subject(:result) { resolve_version(issue.design_collection) }
context 'Neither id nor sha is passed as parameters' do
it 'raises an appropriate error' do
expect { result }.to raise_error(appropriate_error)
end
end
context 'we pass an id' do
let(:params) { { id: global_id_of(first_version) } }
it { is_expected.to eq(first_version) }
end
context 'we pass a sha' do
let(:params) { { sha: first_version.sha } }
it { is_expected.to eq(first_version) }
end
context 'we pass an inconsistent mixture of sha and version id' do
let(:params) { { sha: first_version.sha, id: global_id_of(create(:design_version)) } }
it { is_expected.to be_nil }
end
context 'we pass the id of something that is not a design_version' do
let(:params) { { id: global_id_of(project) } }
it 'raises an appropriate error' do
expect { result }.to raise_error(appropriate_error)
end
end
end
def resolve_version(obj, context = { current_user: current_user })
resolve(resolver, obj: obj, args: params, ctx: context)
end
end
...@@ -6,54 +6,38 @@ describe Resolvers::DesignManagement::VersionResolver do ...@@ -6,54 +6,38 @@ describe Resolvers::DesignManagement::VersionResolver do
include GraphqlHelpers include GraphqlHelpers
include DesignManagementTestHelpers include DesignManagementTestHelpers
let_it_be(:issue) { create(:issue) }
let_it_be(:current_user) { create(:user) }
let_it_be(:version) { create(:design_version, issue: issue) }
let_it_be(:developer) { create(:user) }
let(:project) { issue.project }
let(:params) { { id: global_id_of(version) } }
before do before do
enable_design_management enable_design_management
project.add_developer(developer)
end end
describe "#resolve" do context 'the current user is not authorized' do
let_it_be(:issue) { create(:issue) }
let_it_be(:project) { issue.project }
let_it_be(:first_version) { create(:design_version) }
let_it_be(:first_design) { create(:design, issue: issue, versions: [first_version]) }
let(:current_user) { create(:user) } let(:current_user) { create(:user) }
before do it 'raises an error on resolution' do
project.add_developer(current_user) expect { resolve_version }.to raise_error(::Gitlab::Graphql::Errors::ResourceNotAvailable)
end
context "for a design collection" do
let(:collection) { DesignManagement::DesignCollection.new(issue) }
it "returns the ordered versions" do
second_version = create(:design_version)
create(:design, issue: issue, versions: [second_version])
expect(resolve_versions(collection)).to eq([second_version, first_version])
end
end
context "for a design" do
it "returns the versions" do
expect(resolve_versions(first_design)).to eq([first_version])
end
end end
end
context "when the user is anonymous" do context 'the current user is authorized' do
let(:current_user) { nil } let(:current_user) { developer }
it "returns nothing" do
expect(resolve_versions(first_design)).to be_empty
end
end
context "when the user cannot see designs" do context 'the id parameter is provided' do
it "returns nothing" do it 'returns the specified version' do
expect(resolve_versions(first_design, {}, current_user: create(:user))).to be_empty expect(resolve_version).to eq(version)
end end
end end
end end
def resolve_versions(obj, args = {}, context = { current_user: current_user }) def resolve_version
resolve(described_class, obj: obj, args: args, ctx: context) resolve(described_class, obj: nil, args: params, ctx: { current_user: current_user })
end end
end end
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::DesignManagement::VersionsResolver do
include GraphqlHelpers
include DesignManagementTestHelpers
describe '#resolve' do
let(:resolver) { described_class }
let_it_be(:issue) { create(:issue) }
let_it_be(:authorized_user) { create(:user) }
let_it_be(:first_version) { create(:design_version, issue: issue) }
let_it_be(:other_version) { create(:design_version, issue: issue) }
let_it_be(:first_design) { create(:design, issue: issue, versions: [first_version, other_version]) }
let_it_be(:other_design) { create(:design, :with_versions, issue: issue) }
let(:project) { issue.project }
let(:params) { {} }
let(:current_user) { authorized_user }
let(:parent_args) { { irrelevant: 1.2 } }
let(:parent) { double('Parent', parent: nil, irep_node: double(arguments: parent_args)) }
before do
enable_design_management
project.add_developer(authorized_user)
end
shared_examples 'a source of versions' do
subject(:result) { resolve_versions(object) }
let_it_be(:all_versions) { object.versions.ordered }
context 'when the user is not authorized' do
let(:current_user) { create(:user) }
it { is_expected.to be_empty }
end
context 'without constraints' do
it 'returns the ordered versions' do
expect(result).to eq(all_versions)
end
end
context 'when constrained' do
let_it_be(:matching) { all_versions.earlier_or_equal_to(first_version) }
shared_examples 'a query for all_versions up to the first_version' do
it { is_expected.to eq(matching) }
end
context 'by earlier_or_equal_to_id' do
let(:params) { { id: global_id_of(first_version) } }
it_behaves_like 'a query for all_versions up to the first_version'
end
context 'by earlier_or_equal_to_sha' do
let(:params) { { sha: first_version.sha } }
it_behaves_like 'a query for all_versions up to the first_version'
end
context 'by earlier_or_equal_to_sha AND earlier_or_equal_to_id' do
context 'and they match' do
# This usage is rather dumb, but so long as they match, this will
# return successfully
let(:params) do
{
sha: first_version.sha,
id: global_id_of(first_version)
}
end
it_behaves_like 'a query for all_versions up to the first_version'
end
context 'and they do not match' do
let(:params) do
{
sha: first_version.sha,
id: global_id_of(other_version)
}
end
it 'raises a suitable error' do
expect { result }.to raise_error(GraphQL::ExecutionError)
end
end
end
context 'by at_version in parent' do
let(:parent_args) { { atVersion: global_id_of(first_version) } }
it_behaves_like 'a query for all_versions up to the first_version'
end
end
end
describe 'a design collection' do
let_it_be(:object) { DesignManagement::DesignCollection.new(issue) }
it_behaves_like 'a source of versions'
end
describe 'a design' do
let_it_be(:object) { first_design }
it_behaves_like 'a source of versions'
end
def resolve_versions(obj, context = { current_user: current_user })
eager_resolve(resolver, obj: obj, args: params.merge(parent: parent), ctx: context)
end
end
end
...@@ -5,5 +5,9 @@ require 'spec_helper' ...@@ -5,5 +5,9 @@ require 'spec_helper'
describe GitlabSchema.types['DesignCollection'] do describe GitlabSchema.types['DesignCollection'] do
it { expect(described_class).to require_graphql_authorizations(:read_design) } it { expect(described_class).to require_graphql_authorizations(:read_design) }
it { expect(described_class).to have_graphql_fields(:project, :issue, :designs, :versions) } it 'has the expected fields' do
expected_fields = %i[project issue designs versions version designAtVersion design]
expect(described_class).to have_graphql_fields(*expected_fields)
end
end end
...@@ -5,5 +5,9 @@ require 'spec_helper' ...@@ -5,5 +5,9 @@ require 'spec_helper'
describe GitlabSchema.types['DesignVersion'] do describe GitlabSchema.types['DesignVersion'] do
it { expect(described_class).to require_graphql_authorizations(:read_design) } it { expect(described_class).to require_graphql_authorizations(:read_design) }
it { expect(described_class).to have_graphql_fields(:id, :sha, :designs) } it 'has the expected fields' do
expected_fields = %i[id sha designs design_at_version designs_at_version]
expect(described_class).to have_graphql_fields(*expected_fields)
end
end end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['DesignManagement'] do
it { is_expected.to have_graphql_fields(:version, :design_at_version) }
end
...@@ -8,4 +8,6 @@ describe GitlabSchema.types['Issue'] do ...@@ -8,4 +8,6 @@ describe GitlabSchema.types['Issue'] do
it { expect(described_class).to have_graphql_field(:weight) } it { expect(described_class).to have_graphql_field(:weight) }
it { expect(described_class).to have_graphql_field(:designs) } it { expect(described_class).to have_graphql_field(:designs) }
it { expect(described_class).to have_graphql_field(:design_collection) }
end end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['Query'] do
it do
is_expected.to have_graphql_fields(:design_management).at_least
end
end
...@@ -179,6 +179,57 @@ describe DesignManagement::Design do ...@@ -179,6 +179,57 @@ describe DesignManagement::Design do
end end
end end
describe '#visible_in?' do
set(:issue) { create(:issue) }
# It is expensive to re-create complex histories, so we do it once, and then
# assert that we can establish visibility at any given version.
it 'tells us when a design is visible' do
expected = []
first_design = create(:design, :with_versions, issue: issue, versions_count: 1)
prior_to_creation = first_design.versions.first
expected << [prior_to_creation, :not_created_yet, false]
v = modify_designs(first_design)
expected << [v, :not_created_yet, false]
design = create(:design, :with_versions, issue: issue, versions_count: 1)
created_in = design.versions.first
expected << [created_in, :created, true]
# The future state should not affect the result for any state, so we
# ensure that most states have a long future as well as a rich past
2.times do
v = modify_designs(first_design)
expected << [v, :unaffected_visible, true]
v = modify_designs(design)
expected << [v, :modified, true]
v = modify_designs(first_design)
expected << [v, :unaffected_visible, true]
v = delete_designs(design)
expected << [v, :deleted, false]
v = modify_designs(first_design)
expected << [v, :unaffected_nv, false]
v = restore_designs(design)
expected << [v, :restored, true]
end
delete_designs(design) # ensure visibility is not corelated with current state
got = expected.map do |(v, sym, _)|
[v, sym, design.visible_in?(v)]
end
expect(got).to eq(expected)
end
end
describe '#to_ability_name' do describe '#to_ability_name' do
it { expect(described_class.new.to_ability_name).to eq('design') } it { expect(described_class.new.to_ability_name).to eq('design') }
end end
......
# frozen_string_literal: true
require 'spec_helper'
describe 'Query.project(fullPath).issue(iid).designCollection.version(sha)' do
include GraphqlHelpers
include DesignManagementTestHelpers
let_it_be(:project) { create(:project) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:developer) { create(:user) }
let_it_be(:stranger) { create(:user) }
let_it_be(:old_version) do
create(:design_version, issue: issue,
created_designs: create_list(:design, 3, issue: issue))
end
let_it_be(:version) do
create(:design_version, issue: issue,
modified_designs: old_version.designs,
created_designs: create_list(:design, 2, issue: issue))
end
let(:current_user) { developer }
def query(vq = version_fields)
graphql_query_for(:project, { fullPath: project.full_path },
query_graphql_field(:issue, { iid: issue.iid.to_s },
query_graphql_field(:design_collection, nil,
query_graphql_field(:version, { sha: version.sha }, vq))))
end
let(:post_query) { post_graphql(query, current_user: current_user) }
let(:path_prefix) { %w[project issue designCollection version] }
let(:data) { graphql_data.dig(*path) }
before do
enable_design_management
project.add_developer(developer)
end
describe 'scalar fields' do
let(:path) { path_prefix }
let(:version_fields) { query_graphql_field(:sha) }
before do
post_query
end
{ id: ->(x) { x.to_global_id.to_s }, sha: ->(x) { x.sha } }.each do |field, value|
describe ".#{field}" do
let(:version_fields) { query_graphql_field(field) }
it "retrieves the #{field}" do
expect(data).to match(a_hash_including(field.to_s => value[version]))
end
end
end
end
describe 'design_at_version' do
let(:path) { path_prefix + %w[designAtVersion] }
let(:design) { issue.designs.visible_at_version(version).to_a.sample }
let(:design_at_version) { build(:design_at_version, design: design, version: version) }
let(:version_fields) do
query_graphql_field(:design_at_version, dav_params, 'id filename')
end
shared_examples :finds_dav do
it 'finds all the designs as of the given version' do
post_query
expect(data).to match(
a_hash_including(
'id' => global_id_of(design_at_version),
'filename' => design.filename
))
end
context 'when the current_user is not authorized' do
let(:current_user) { stranger }
it 'returns nil' do
post_query
expect(data).to be_nil
end
end
end
context 'by ID' do
let(:dav_params) { { id: global_id_of(design_at_version) } }
include_examples :finds_dav
end
context 'by filename' do
let(:dav_params) { { filename: design.filename } }
include_examples :finds_dav
end
context 'by design_id' do
let(:dav_params) { { design_id: global_id_of(design) } }
include_examples :finds_dav
end
end
describe 'designs_at_version' do
let(:path) { path_prefix + %w[designsAtVersion edges] }
let(:version_fields) do
query_graphql_field(:designs_at_version, dav_params, 'edges { node { id filename } }')
end
let(:dav_params) { nil }
let(:results) do
issue.designs.visible_at_version(version).map do |d|
dav = build(:design_at_version, design: d, version: version)
{ 'id' => global_id_of(dav), 'filename' => d.filename }
end
end
it 'finds all the designs as of the given version' do
post_query
expect(data.pluck('node')).to match_array(results)
end
describe 'filtering' do
let(:designs) { issue.designs.sample(3) }
let(:filenames) { designs.map(&:filename) }
let(:ids) do
designs.map { |d| global_id_of(build(:design_at_version, design: d, version: version)) }
end
before do
post_query
end
describe 'by filename' do
let(:dav_params) { { filenames: filenames } }
it 'finds the designs by filename' do
expect(data.map { |e| e.dig('node', 'id') }).to match_array(ids)
end
end
describe 'by design-id' do
let(:dav_params) { { ids: designs.map { |d| global_id_of(d) } } }
it 'finds the designs by id' do
expect(data.map { |e| e.dig('node', 'filename') }).to match_array(filenames)
end
end
end
describe 'pagination' do
let(:end_cursor) { graphql_data_at(*path_prefix, :designs_at_version, :page_info, :end_cursor) }
let(:ids) do
::DesignManagement::Design.visible_at_version(version).order(:id).map do |d|
global_id_of(build(:design_at_version, design: d, version: version))
end
end
let(:version_fields) do
query_graphql_field(:designs_at_version, { first: 2 }, fields)
end
let(:cursored_query) do
frag = query_graphql_field(:designs_at_version, { after: end_cursor }, fields)
query(frag)
end
let(:fields) { ['pageInfo { endCursor }', 'edges { node { id } }'] }
def response_values(data = graphql_data)
data.dig(*path).map { |e| e.dig('node', 'id') }
end
it 'sorts designs for reliable pagination' do
post_graphql(query, current_user: current_user)
expect(response_values).to match_array(ids.take(2))
post_graphql(cursored_query, current_user: current_user)
new_data = JSON.parse(response.body).fetch('data')
expect(response_values(new_data)).to match_array(ids.drop(2))
end
end
end
describe 'designs' do
let(:path) { path_prefix + %w[designs edges] }
let(:version_fields) do
query_graphql_field(:designs, nil, 'edges { node { id filename } }')
end
let(:results) do
version.designs.map do |design|
{ 'id' => global_id_of(design), 'filename' => design.filename }
end
end
it 'finds all the designs as of the given version' do
post_query
expect(data.pluck('node')).to match_array(results)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe 'Getting versions related to an issue' do
include GraphqlHelpers
include DesignManagementTestHelpers
let_it_be(:issue) { create(:issue) }
let_it_be(:version_a) do
create(:design_version, issue: issue)
end
let_it_be(:version_b) do
create(:design_version, issue: issue)
end
let_it_be(:version_c) do
create(:design_version, issue: issue)
end
let_it_be(:version_d) do
create(:design_version, issue: issue)
end
let_it_be(:owner) { issue.project.owner }
def version_query(params = version_params)
query_graphql_field(:versions, params, version_query_fields)
end
let(:version_params) { nil }
let(:version_query_fields) { ['edges { node { sha } }'] }
let(:project) { issue.project }
let(:current_user) { owner }
let(:query) { make_query }
def make_query(vq = version_query)
graphql_query_for(:project, { fullPath: project.full_path },
query_graphql_field(:issue, { iid: issue.iid.to_s },
query_graphql_field(:design_collection, {}, vq)))
end
let(:design_collection) do
graphql_data_at(:project, :issue, :design_collection)
end
def response_values(data = graphql_data, key = 'sha')
path = %w[project issue designCollection versions edges]
data.dig(*path).map { |e| e.dig('node', key) }
end
before do
enable_design_management
end
it 'returns the design filename' do
post_graphql(query, current_user: current_user)
expect(response_values).to match_array([version_a, version_b, version_c, version_d].map(&:sha))
end
describe 'filter by sha' do
let(:sha) { version_b.sha }
let(:version_params) { { earlier_or_equal_to_sha: sha } }
it 'finds only those versions at or before the given cut-off' do
post_graphql(query, current_user: current_user)
expect(response_values).to contain_exactly(version_a.sha, version_b.sha)
end
end
describe 'filter by id' do
let(:id) { global_id_of(version_c) }
let(:version_params) { { earlier_or_equal_to_id: id } }
it 'finds only those versions at or before the given cut-off' do
post_graphql(query, current_user: current_user)
expect(response_values).to contain_exactly(version_a.sha, version_b.sha, version_c.sha)
end
end
describe 'pagination' do
let(:end_cursor) { design_collection.dig('versions', 'pageInfo', 'endCursor') }
let(:ids) { issue.design_collection.versions.ordered.map(&:sha) }
let(:query) { make_query(version_query(first: 2)) }
let(:cursored_query) do
make_query(version_query(after: end_cursor))
end
let(:version_query_fields) { ['pageInfo { endCursor }', 'edges { node { sha } }'] }
it 'sorts designs for reliable pagination' do
post_graphql(query, current_user: current_user)
expect(response_values).to match_array(ids.take(2))
post_graphql(cursored_query, current_user: current_user)
new_data = JSON.parse(response.body).fetch('data')
expect(response_values(new_data)).to match_array(ids.drop(2))
end
end
end
...@@ -60,7 +60,7 @@ describe 'Getting designs related to an issue' do ...@@ -60,7 +60,7 @@ describe 'Getting designs related to an issue' do
{ 'fullPath' => design.project.full_path }, { 'fullPath' => design.project.full_path },
query_graphql_field( query_graphql_field(
'issue', 'issue',
{ iid: design.issue.iid }, { iid: design.issue.iid.to_s },
query_graphql_field( query_graphql_field(
'designs', {}, design_node 'designs', {}, design_node
) )
......
# frozen_string_literal: true
require 'spec_helper'
describe 'Query.project(fullPath).issue(iid)' do
include GraphqlHelpers
let_it_be(:project) { create(:project) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:issue_b) { create(:issue, project: project) }
let_it_be(:developer) { create(:user) }
let(:current_user) { developer }
let_it_be(:project_params) { { 'fullPath' => project.full_path } }
let_it_be(:issue_params) { { 'iid' => issue.iid.to_s } }
let_it_be(:issue_fields) { 'title' }
let(:query) do
graphql_query_for('project', project_params, project_fields)
end
let(:project_fields) do
query_graphql_field(:issue, issue_params, issue_fields)
end
shared_examples 'being able to fetch a design-like object by ID' do
let(:design) { design_a }
let(:path) { %w[project issue designCollection] + [GraphqlHelpers.fieldnamerize(object_field_name)] }
let(:design_fields) do
[
query_graphql_field(:filename),
query_graphql_field(:project, nil, query_graphql_field(:id))
]
end
let(:design_collection_fields) do
query_graphql_field(object_field_name, object_params, object_fields)
end
let(:object_fields) { design_fields }
context 'the ID is passed' do
let(:object_params) { { id: global_id_of(object) } }
let(:result_fields) { {} }
let(:expected_fields) do
result_fields.merge({ 'filename' => design.filename, 'project' => id_hash(project) })
end
it 'retrieves the object' do
post_query
data = graphql_data.dig(*path)
expect(data).to match(a_hash_including(expected_fields))
end
context 'the user is unauthorized' do
let(:current_user) { create(:user) }
it_behaves_like 'a failure to find anything'
end
end
context 'without parameters' do
let(:object_params) { nil }
it 'raises an error' do
post_query
expect(graphql_errors).to include(no_argument_error)
end
end
context 'attempting to retrieve an object from a different issue' do
let(:object_params) { { id: global_id_of(object_on_other_issue) } }
it_behaves_like 'a failure to find anything'
end
end
before do
project.add_developer(developer)
end
let(:post_query) { post_graphql(query, current_user: current_user) }
describe '.designCollection' do
include DesignManagementTestHelpers
let_it_be(:design_a) { create(:design, issue: issue) }
let_it_be(:version_a) { create(:design_version, issue: issue, created_designs: [design_a]) }
let(:issue_fields) do
query_graphql_field(:design_collection, dc_params, design_collection_fields)
end
let(:dc_params) { nil }
let(:design_collection_fields) { nil }
before do
enable_design_management
end
describe '.design' do
let(:object) { design }
let(:object_field_name) { :design }
let(:no_argument_error) do
custom_graphql_error(path, a_string_matching(%r/id or filename/))
end
let_it_be(:object_on_other_issue) { create(:design, issue: issue_b) }
it_behaves_like 'being able to fetch a design-like object by ID'
it_behaves_like 'being able to fetch a design-like object by ID' do
let(:object_params) { { filename: design.filename } }
end
end
describe '.version' do
let(:version) { version_a }
let(:path) { %w[project issue designCollection version] }
let(:design_collection_fields) do
query_graphql_field(:version, version_params, 'id sha')
end
context 'no parameters' do
let(:version_params) { nil }
it 'raises an error' do
post_query
expect(graphql_errors).to include(custom_graphql_error(path, a_string_matching(%r/id or sha/)))
end
end
shared_examples 'a successful query for a version' do
it 'finds the version' do
post_query
data = graphql_data.dig(*path)
expect(data).to match(
a_hash_including('id' => global_id_of(version),
'sha' => version.sha)
)
end
end
context '(sha: STRING_TYPE)' do
let(:version_params) { { sha: version.sha } }
it_behaves_like 'a successful query for a version'
end
context '(id: ID_TYPE)' do
let(:version_params) { { id: global_id_of(version) } }
it_behaves_like 'a successful query for a version'
end
end
describe '.designAtVersion' do
it_behaves_like 'being able to fetch a design-like object by ID' do
let(:object) { build(:design_at_version, design: design, version: version) }
let(:object_field_name) { :design_at_version }
let(:version) { version_a }
let(:result_fields) { { 'version' => id_hash(version) } }
let(:object_fields) do
design_fields + [query_graphql_field(:version, nil, query_graphql_field(:id))]
end
let(:no_argument_error) { missing_required_argument(path, :id) }
let(:object_on_other_issue) { build(:design_at_version, issue: issue_b) }
end
end
end
def id_hash(object)
a_hash_including('id' => global_id_of(object))
end
end
# frozen_string_literal: true
require 'spec_helper'
describe 'Query' do
include GraphqlHelpers
let_it_be(:project) { create(:project) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:developer) { create(:user) }
let(:current_user) { developer }
describe '.designManagement' do
include DesignManagementTestHelpers
let_it_be(:version) { create(:design_version, issue: issue) }
let_it_be(:design) { version.designs.first }
let(:query_result) { graphql_data.dig(*path) }
let(:query) { graphql_query_for(:design_management, nil, dm_fields) }
before do
enable_design_management
project.add_developer(developer)
post_graphql(query, current_user: current_user)
end
shared_examples 'a query that needs authorization' do
context 'the current user is not able to read designs' do
let(:current_user) { create(:user) }
it 'does not retrieve the record' do
expect(query_result).to be_nil
end
it 'raises an error' do
expect(graphql_errors).to include(
a_hash_including('message' => a_string_matching(%r{you don't have permission}))
)
end
end
end
describe '.version' do
let(:path) { %w[designManagement version] }
let(:dm_fields) do
query_graphql_field(:version, { 'id' => global_id_of(version) }, 'id sha')
end
it_behaves_like 'a working graphql query'
it_behaves_like 'a query that needs authorization'
context 'the current user is able to read designs' do
it 'fetches the expected data' do
expect(query_result).to eq('id' => global_id_of(version), 'sha' => version.sha)
end
end
end
describe '.designAtVersion' do
let_it_be(:design_at_version) do
::DesignManagement::DesignAtVersion.new(design: design, version: version)
end
let(:path) { %w[designManagement designAtVersion] }
let(:dm_fields) do
query_graphql_field(:design_at_version, { 'id' => global_id_of(design_at_version) }, <<~FIELDS)
id
filename
version { id sha }
design { id }
issue { title iid }
project { id fullPath }
FIELDS
end
it_behaves_like 'a working graphql query'
it_behaves_like 'a query that needs authorization'
context 'the current user is able to read designs' do
it 'fetches the expected data, including the correct associations' do
expect(query_result).to eq(
'id' => global_id_of(design_at_version),
'filename' => design_at_version.design.filename,
'version' => { 'id' => global_id_of(version), 'sha' => version.sha },
'design' => { 'id' => global_id_of(design) },
'issue' => { 'title' => issue.title, 'iid' => issue.iid.to_s },
'project' => { 'id' => global_id_of(project), 'fullPath' => project.full_path }
)
end
end
end
end
end
# frozen_string_literal: true
shared_context 'four designs in three versions' do
include DesignManagementTestHelpers
let_it_be(:issue) { create(:issue) }
let_it_be(:project) { issue.project }
let_it_be(:authorized_user) { create(:user) }
let_it_be(:design_a) { create(:design, issue: issue) }
let_it_be(:design_b) { create(:design, issue: issue) }
let_it_be(:design_c) { create(:design, issue: issue) }
let_it_be(:design_d) { create(:design, issue: issue) }
let_it_be(:first_version) do
create(:design_version, issue: issue,
created_designs: [design_a],
modified_designs: [],
deleted_designs: [])
end
let_it_be(:second_version) do
create(:design_version, issue: issue,
created_designs: [design_b, design_c, design_d],
modified_designs: [design_a],
deleted_designs: [])
end
let_it_be(:third_version) do
create(:design_version, issue: issue,
created_designs: [],
modified_designs: [design_a],
deleted_designs: [design_d])
end
before do
enable_design_management
project.add_developer(authorized_user)
end
end
...@@ -7,15 +7,10 @@ describe GitlabSchema.types['Query'] do ...@@ -7,15 +7,10 @@ describe GitlabSchema.types['Query'] do
expect(described_class.graphql_name).to eq('Query') expect(described_class.graphql_name).to eq('Query')
end end
it do it 'has the expected fields' do
is_expected.to have_graphql_fields(:project, expected_fields = %i[project namespace group echo metadata current_user snippets]
:namespace,
:group, expect(described_class).to have_graphql_fields(*expected_fields).at_least
:echo,
:metadata,
:current_user,
:snippets
).at_least
end end
describe 'namespace field' do describe 'namespace field' do
......
...@@ -14,7 +14,7 @@ describe 'getting merge request information nested in a project' do ...@@ -14,7 +14,7 @@ describe 'getting merge request information nested in a project' do
graphql_query_for( graphql_query_for(
'project', 'project',
{ 'fullPath' => project.full_path }, { 'fullPath' => project.full_path },
query_graphql_field('mergeRequest', iid: merge_request.iid) query_graphql_field('mergeRequest', iid: merge_request.iid.to_s)
) )
end end
......
...@@ -25,7 +25,7 @@ describe 'getting task completion status information' do ...@@ -25,7 +25,7 @@ describe 'getting task completion status information' do
graphql_query_for( graphql_query_for(
'project', 'project',
{ 'fullPath' => project.full_path }, { 'fullPath' => project.full_path },
query_graphql_field(type, { iid: iid }, fields) query_graphql_field(type, { iid: iid.to_s }, fields)
) )
end end
......
...@@ -16,6 +16,20 @@ module GraphqlHelpers ...@@ -16,6 +16,20 @@ module GraphqlHelpers
resolver_class.new(object: obj, context: ctx).resolve(args) resolver_class.new(object: obj, context: ctx).resolve(args)
end end
# Eagerly run a loader's named resolver
# (syncs any lazy values returned by resolve)
def eager_resolve(resolver_class, **opts)
sync(resolve(resolver_class, **opts))
end
def sync(value)
if GitlabSchema.lazy?(value)
GitlabSchema.sync_lazy(value)
else
value
end
end
# Runs a block inside a BatchLoader::Executor wrapper # Runs a block inside a BatchLoader::Executor wrapper
def batch(max_queries: nil, &blk) def batch(max_queries: nil, &blk)
wrapper = proc do wrapper = proc do
...@@ -39,7 +53,7 @@ module GraphqlHelpers ...@@ -39,7 +53,7 @@ module GraphqlHelpers
def batch_sync(max_queries: nil, &blk) def batch_sync(max_queries: nil, &blk)
wrapper = proc do wrapper = proc do
lazy_vals = yield lazy_vals = yield
lazy_vals.is_a?(Array) ? lazy_vals.map(&:sync) : lazy_vals&.sync lazy_vals.is_a?(Array) ? lazy_vals.map { |val| sync(val) } : sync(lazy_vals)
end end
batch(max_queries: max_queries, &wrapper) batch(max_queries: max_queries, &wrapper)
...@@ -164,16 +178,26 @@ module GraphqlHelpers ...@@ -164,16 +178,26 @@ module GraphqlHelpers
def attributes_to_graphql(attributes) def attributes_to_graphql(attributes)
attributes.map do |name, value| attributes.map do |name, value|
value_str = if value.is_a?(Array) value_str = as_graphql_literal(value)
'["' + value.join('","') + '"]'
else
"\"#{value}\""
end
"#{GraphqlHelpers.fieldnamerize(name.to_s)}: #{value_str}" "#{GraphqlHelpers.fieldnamerize(name.to_s)}: #{value_str}"
end.join(", ") end.join(", ")
end end
# Fairly dumb Ruby => GraphQL rendering function. Only suitable for testing.
# Missing support for Enums (feel free to add if you need it).
def as_graphql_literal(value)
case value
when Array then "[#{value.map { |v| as_graphql_literal(v) }.join(',')}]"
when Integer, Float then value.to_s
when String then "\"#{value.gsub(/"/, '\\"')}\""
when nil then 'null'
when true then 'true'
when false then 'false'
else raise ArgumentError, "Cannot represent #{value} as GraphQL literal"
end
end
def post_multiplex(queries, current_user: nil, headers: {}) def post_multiplex(queries, current_user: nil, headers: {})
post api('/', current_user, version: 'graphql'), params: { _json: queries }, headers: headers post api('/', current_user, version: 'graphql'), params: { _json: queries }, headers: headers
end end
...@@ -216,6 +240,11 @@ module GraphqlHelpers ...@@ -216,6 +240,11 @@ module GraphqlHelpers
json_response['data'] || (raise NoData, graphql_errors) json_response['data'] || (raise NoData, graphql_errors)
end end
def graphql_data_at(*path)
keys = path.map { |segment| GraphqlHelpers.fieldnamerize(segment) }
graphql_data.dig(*keys)
end
def graphql_errors def graphql_errors
case json_response case json_response
when Hash # regular query when Hash # regular query
......
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