Commit aacf9424 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'dmishunov-multiple-snippet-queries' into 'master'

Support per-blob queries in snippets

See merge request gitlab-org/gitlab!42809
parents f327577b 6c73ac27
......@@ -151,7 +151,7 @@ export default {
this.newSnippet = false;
},
onSnippetFetch(snippetRes) {
if (snippetRes.data.snippets.edges.length === 0) {
if (snippetRes.data.snippets.nodes.length === 0) {
this.onNewSnippetFetched();
} else {
this.onExistingSnippetFetched();
......
......@@ -23,6 +23,7 @@ export default {
return {
ids: this.snippet.id,
rich: this.activeViewerType === RICH_BLOB_VIEWER,
paths: [this.blob.path],
};
},
update(data) {
......@@ -79,8 +80,10 @@ export default {
},
onContentUpdate(data) {
const { path: blobPath } = this.blob;
const { blobs } = data.snippets.edges[0].node;
const updatedBlobData = blobs.find(blob => blob.path === blobPath);
const {
blobs: { nodes: dataBlobs },
} = data.snippets.nodes[0];
const updatedBlobData = dataBlobs.find(blob => blob.path === blobPath);
return updatedBlobData.richData || updatedBlobData.plainData;
},
},
......
......@@ -18,6 +18,7 @@ import DeleteSnippetMutation from '../mutations/deleteSnippet.mutation.graphql';
import CanCreatePersonalSnippet from '../queries/userPermissions.query.graphql';
import CanCreateProjectSnippet from '../queries/projectPermissions.query.graphql';
import { joinPaths } from '~/lib/utils/url_utility';
import { fetchPolicies } from '~/lib/graphql';
export default {
components: {
......@@ -37,6 +38,7 @@ export default {
},
apollo: {
canCreateSnippet: {
fetchPolicy: fetchPolicies.NO_CACHE,
query() {
return this.snippet.project ? CanCreateProjectSnippet : CanCreatePersonalSnippet;
},
......
......@@ -12,6 +12,7 @@ fragment SnippetBase on Snippet {
httpUrlToRepo
sshUrlToRepo
blobs {
nodes {
binary
name
path
......@@ -26,6 +27,7 @@ fragment SnippetBase on Snippet {
...BlobViewer
}
}
}
userPermissions {
adminSnippet
updateSnippet
......
......@@ -16,7 +16,7 @@ function appFactory(el, Component) {
}
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
defaultClient: createDefaultClient({}, { batchMax: 1 }),
});
const {
......
......@@ -11,9 +11,16 @@ export const getSnippetMixin = {
ids: this.snippetGid,
};
},
update: data => data.snippets.edges[0]?.node,
update: data => {
const res = data.snippets.nodes[0];
if (res) {
res.blobs = res.blobs.nodes;
}
return res;
},
result(res) {
this.blobs = res.data.snippets.edges[0]?.node?.blobs || blobsDefault;
this.blobs = res.data.snippets.nodes[0]?.blobs || blobsDefault;
if (this.onSnippetFetch) {
this.onSnippetFetch(res);
}
......
query SnippetBlobContent($ids: [ID!], $rich: Boolean!) {
query SnippetBlobContent($ids: [ID!], $rich: Boolean!, $paths: [String!]) {
snippets(ids: $ids) {
edges {
node {
nodes {
id
blobs {
blobs(paths: $paths) {
nodes {
path
richData @include(if: $rich)
plainData @skip(if: $rich)
......
......@@ -4,8 +4,7 @@
query GetSnippetQuery($ids: [ID!]) {
snippets(ids: $ids) {
edges {
node {
nodes {
...SnippetBase
...SnippetProject
author {
......@@ -13,5 +12,4 @@ query GetSnippetQuery($ids: [ID!]) {
}
}
}
}
}
# frozen_string_literal: true
module Resolvers
module Snippets
class BlobsResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
alias_method :snippet, :object
argument :paths, [GraphQL::STRING_TYPE],
required: false,
description: 'Paths of the blobs'
def resolve(**args)
authorize!(snippet)
return [snippet.blob] if snippet.empty_repo?
paths = Array(args.fetch(:paths, []))
if paths.empty?
snippet.blobs
else
snippet.repository.blobs_at(transformed_blob_paths(paths))
end
end
def authorized_resource?(snippet)
Ability.allowed?(context[:current_user], :read_snippet, snippet)
end
private
def transformed_blob_paths(paths)
ref = snippet.default_branch
paths.map { |path| [ref, path] }
end
end
end
end
......@@ -69,10 +69,11 @@ module Types
null: false,
deprecated: { reason: 'Use `blobs`', milestone: '13.3' }
field :blobs, type: [Types::Snippets::BlobType],
field :blobs, type: Types::Snippets::BlobType.connection_type,
description: 'Snippet blobs',
calls_gitaly: true,
null: false
null: true,
resolver: Resolvers::Snippets::BlobsResolver
field :ssh_url_to_repo, type: GraphQL::STRING_TYPE,
description: 'SSH URL to the snippet repository',
......
......@@ -32,15 +32,9 @@ class SnippetPresenter < Gitlab::View::Presenter::Delegated
end
def blob
blobs.first
end
return snippet.blob if snippet.empty_repo?
def blobs
if snippet.empty_repo?
[snippet.blob]
else
snippet.blobs
end
blobs.first
end
private
......
......@@ -16250,7 +16250,32 @@ type Snippet implements Noteable {
"""
Snippet blobs
"""
blobs: [SnippetBlob!]!
blobs(
"""
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
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
Paths of the blobs
"""
paths: [String!]
): SnippetBlobConnection
"""
Timestamp this snippet was created
......@@ -16473,6 +16498,41 @@ input SnippetBlobActionInputType {
previousPath: String
}
"""
The connection type for SnippetBlob.
"""
type SnippetBlobConnection {
"""
A list of edges.
"""
edges: [SnippetBlobEdge]
"""
A list of nodes.
"""
nodes: [SnippetBlob]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type SnippetBlobEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: SnippetBlob
}
"""
Represents how the blob content should be displayed
"""
......
......@@ -47549,24 +47549,69 @@
"name": "blobs",
"description": "Snippet blobs",
"args": [
],
{
"name": "paths",
"description": "Paths of the blobs",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "SnippetBlob",
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null
},
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "before",
"description": "Returns the elements in the list that come before the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": "Returns the first _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": "Returns the last _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "SnippetBlobConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
......@@ -48220,6 +48265,118 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SnippetBlobConnection",
"description": "The connection type for SnippetBlob.",
"fields": [
{
"name": "edges",
"description": "A list of edges.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "SnippetBlobEdge",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "nodes",
"description": "A list of nodes.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "SnippetBlob",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pageInfo",
"description": "Information to aid in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PageInfo",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SnippetBlobEdge",
"description": "An edge in a connection.",
"fields": [
{
"name": "cursor",
"description": "A cursor for use in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "node",
"description": "The item at the end of the edge.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "SnippetBlob",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SnippetBlobViewer",
......@@ -2303,7 +2303,6 @@ Represents a snippet entry.
| ----- | ---- | ----------- |
| `author` | User | The owner of the snippet |
| `blob` **{warning-solid}** | SnippetBlob! | **Deprecated:** Use `blobs`. Deprecated in 13.3 |
| `blobs` | SnippetBlob! => Array | Snippet blobs |
| `createdAt` | Time! | Timestamp this snippet was created |
| `description` | String | Description of the snippet |
| `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` |
......
......@@ -148,17 +148,17 @@ describe('Snippet Edit app', () => {
// Ideally we wouldn't call this method directly, but we don't have a way to trigger
// apollo responses yet.
const loadSnippet = (...edges) => {
if (edges.length) {
const loadSnippet = (...nodes) => {
if (nodes.length) {
wrapper.setData({
snippet: edges[0],
snippet: nodes[0],
});
}
wrapper.vm.onSnippetFetch({
data: {
snippets: {
edges,
nodes,
},
},
});
......
......@@ -140,10 +140,10 @@ describe('Blob Embeddable', () => {
async ({ snippetBlobs, currentBlob, expectedContent }) => {
const apolloData = {
snippets: {
edges: [
nodes: [
{
node: {
blobs: snippetBlobs,
blobs: {
nodes: snippetBlobs,
},
},
],
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::Snippets::BlobsResolver do
include GraphqlHelpers
describe '#resolve' do
let_it_be(:current_user) { create(:user) }
let_it_be(:snippet) { create(:personal_snippet, :private, :repository, author: current_user) }
context 'when user is not authorized' do
let(:other_user) { create(:user) }
it 'raises an error' do
expect do
resolve_blobs(snippet, user: other_user)
end.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when using no filter' do
it 'returns all snippet blobs' do
expect(resolve_blobs(snippet).map(&:path)).to contain_exactly(*snippet.list_files)
end
end
context 'when using filters' do
context 'when paths is a single string' do
it 'returns an array of files' do
path = 'CHANGELOG'
expect(resolve_blobs(snippet, args: { paths: path }).first.path).to eq(path)
end
end
context 'when paths is an array of string' do
it 'returns an array of files' do
paths = ['CHANGELOG', 'README.md']
expect(resolve_blobs(snippet, args: { paths: paths }).map(&:path)).to contain_exactly(*paths)
end
end
end
end
def resolve_blobs(snippet, user: current_user, args: {})
resolve(described_class, args: args, ctx: { current_user: user }, obj: snippet)
end
end
......@@ -16,6 +16,15 @@ RSpec.describe GitlabSchema.types['Snippet'] do
expect(described_class).to have_graphql_fields(*expected_fields)
end
describe 'blobs field' do
subject { described_class.fields['blobs'] }
it 'returns blobs' do
is_expected.to have_graphql_type(Types::Snippets::BlobType.connection_type)
is_expected.to have_graphql_resolver(Resolvers::Snippets::BlobsResolver)
end
end
context 'when restricted visibility level is set to public' do
let_it_be(:snippet) { create(:personal_snippet, :repository, :public, author: user) }
......@@ -142,9 +151,30 @@ RSpec.describe GitlabSchema.types['Snippet'] do
describe '#blobs' do
let_it_be(:snippet) { create(:personal_snippet, :public, author: user) }
let(:query_blobs) { subject.dig('data', 'snippets', 'edges')[0]['node']['blobs'] }
let(:query_blobs) { subject.dig('data', 'snippets', 'edges')[0].dig('node', 'blobs', 'edges') }
let(:paths) { [] }
let(:query) do
%(
{
snippets {
edges {
node {
blobs(paths: #{paths}) {
edges {
node {
name
path
}
}
}
}
}
}
}
)
end
subject { GitlabSchema.execute(snippet_query_for(field: 'blobs'), context: { current_user: user }).as_json }
subject { GitlabSchema.execute(query, context: { current_user: user }).as_json }
shared_examples 'an array' do
it 'returns an array of snippet blobs' do
......@@ -158,8 +188,8 @@ RSpec.describe GitlabSchema.types['Snippet'] do
it_behaves_like 'an array'
it 'contains the first blob from the snippet' do
expect(query_blobs.first['name']).to eq blob.name
expect(query_blobs.first['path']).to eq blob.path
expect(query_blobs.first['node']['name']).to eq blob.name
expect(query_blobs.first['node']['path']).to eq blob.path
end
end
......@@ -170,10 +200,22 @@ RSpec.describe GitlabSchema.types['Snippet'] do
it_behaves_like 'an array'
it 'contains all the blobs from the repository' do
resulting_blobs_names = query_blobs.map { |b| b['name'] }
resulting_blobs_names = query_blobs.map { |b| b['node']['name'] }
expect(resulting_blobs_names).to match_array(blobs.map(&:name))
end
context 'when specific path is set' do
let(:paths) { ['CHANGELOG'] }
it_behaves_like 'an array'
it 'returns specific files' do
resulting_blobs_names = query_blobs.map { |b| b['node']['name'] }
expect(resulting_blobs_names).to match(paths)
end
end
end
end
......
......@@ -163,25 +163,4 @@ RSpec.describe SnippetPresenter do
end
end
end
describe '#blobs' do
let(:snippet) { personal_snippet }
subject { presenter.blobs }
context 'when snippet does not have a repository' do
it 'returns an array with one SnippetBlob' do
expect(subject.size).to eq(1)
expect(subject.first).to eq(snippet.blob)
end
end
context 'when snippet has a repository' do
let(:snippet) { create(:snippet, :repository, author: user) }
it 'returns an array with all repository blobs' do
expect(subject).to match_array(snippet.blobs)
end
end
end
end
......@@ -79,18 +79,20 @@ RSpec.describe 'Creating a Snippet' do
end
shared_examples 'creates snippet' do
it 'returns the created Snippet' do
it 'returns the created Snippet', :aggregate_failures do
expect do
subject
end.to change { Snippet.count }.by(1)
snippet = Snippet.last
created_file_1 = snippet.repository.blob_at('HEAD', file_1[:filePath])
created_file_2 = snippet.repository.blob_at('HEAD', file_2[:filePath])
expect(created_file_1.data).to match(file_1[:content])
expect(created_file_2.data).to match(file_2[:content])
expect(mutation_response['snippet']['title']).to eq(title)
expect(mutation_response['snippet']['description']).to eq(description)
expect(mutation_response['snippet']['visibilityLevel']).to eq(visibility_level)
expect(mutation_response['snippet']['blobs'][0]['plainData']).to match(file_1[:content])
expect(mutation_response['snippet']['blobs'][0]['fileName']).to match(file_1[:file_path])
expect(mutation_response['snippet']['blobs'][1]['plainData']).to match(file_2[:content])
expect(mutation_response['snippet']['blobs'][1]['fileName']).to match(file_2[:file_path])
end
context 'when action is invalid' do
......
......@@ -73,7 +73,6 @@ RSpec.describe 'Updating a Snippet' do
aggregate_failures do
expect(blob_to_update.data).to eq updated_content
expect(blob_to_delete).to be_nil
expect(blob_in_mutation_response(updated_file)['plainData']).to match(updated_content)
expect(mutation_response['snippet']['title']).to eq(updated_title)
expect(mutation_response['snippet']['description']).to eq(updated_description)
expect(mutation_response['snippet']['visibilityLevel']).to eq('public')
......@@ -100,7 +99,6 @@ RSpec.describe 'Updating a Snippet' do
aggregate_failures do
expect(blob_at(updated_file).data).to eq blob_to_update.data
expect(blob_at(deleted_file).data).to eq blob_to_delete.data
expect(blob_in_mutation_response(deleted_file)['plainData']).not_to be_nil
expect(mutation_response['snippet']['title']).to eq(original_title)
expect(mutation_response['snippet']['description']).to eq(original_description)
expect(mutation_response['snippet']['visibilityLevel']).to eq('private')
......@@ -108,10 +106,6 @@ RSpec.describe 'Updating a Snippet' do
end
end
def blob_in_mutation_response(filename)
mutation_response['snippet']['blobs'].select { |blob| blob['name'] == filename }[0]
end
def blob_at(filename)
snippet.repository.blob_at('HEAD', filename)
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