From 40b8edeec1e92a8161005369a16359f78a0952ce Mon Sep 17 00:00:00 2001 From: Eugenia Grieff <egrieff@gitlab.com> Date: Tue, 23 Aug 2022 23:40:37 +0000 Subject: [PATCH] Allow to specify the fields to search for when querying work items Refactor the existing logic for epics to apply it to all issuables Changelog: added EE: true --- .../concerns/issue_resolver_arguments.rb | 61 ++++++------ .../resolvers/concerns/search_arguments.rb | 36 +++++++ app/graphql/resolvers/work_items_resolver.rb | 35 +++---- doc/api/graphql/reference/index.md | 17 ++-- ee/app/graphql/resolvers/epics_resolver.rb | 32 +++---- .../graphql/resolvers/epics_resolver_spec.rb | 86 ++--------------- .../requests/api/graphql/group/epics_spec.rb | 14 +-- .../graphql/resolvers/issues_resolver_spec.rb | 52 ++-------- .../resolvers/work_items_resolver_spec.rb | 52 ++-------- .../api/graphql/project/issues_spec.rb | 24 +++-- .../api/graphql/project/work_items_spec.rb | 24 +++-- .../issuable_resolvers_shared_examples.rb | 95 +++++++++++++++++++ .../issuable_search_shared_examples.rb | 14 +++ 13 files changed, 285 insertions(+), 257 deletions(-) create mode 100644 spec/support/shared_examples/graphql/resolvers/issuable_resolvers_shared_examples.rb create mode 100644 spec/support/shared_examples/requests/api/graphql/issuable_search_shared_examples.rb diff --git a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb index fe213936f555..15ab44352045 100644 --- a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb +++ b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb @@ -76,34 +76,18 @@ module IssueResolverArguments end def resolve_with_lookahead(**args) - # The project could have been loaded in batch by `BatchLoader`. - # At this point we need the `id` of the project to query for issues, so - # make sure it's loaded and not `nil` before continuing. - parent = object.respond_to?(:sync) ? object.sync : object - return Issue.none if parent.nil? - - # Will need to be made group & namespace aware with - # https://gitlab.com/gitlab-org/gitlab-foss/issues/54520 - args[:not] = args[:not].to_h if args[:not].present? - args[:iids] ||= [args.delete(:iid)].compact if args[:iid] - args[:attempt_project_search_optimizations] = true if args[:search].present? - - prepare_assignee_username_params(args) - prepare_release_tag_params(args) + return Issue.none if resource_parent.nil? - finder = IssuesFinder.new(current_user, args) + finder = IssuesFinder.new(current_user, prepare_finder_params(args)) - continue_issue_resolve(parent, finder, **args) + continue_issue_resolve(resource_parent, finder, **args) end def ready?(**args) - args[:not] = args[:not].to_h if args[:not].present? - params_not_mutually_exclusive(args, mutually_exclusive_assignee_username_args) params_not_mutually_exclusive(args, mutually_exclusive_milestone_args) params_not_mutually_exclusive(args.fetch(:not, {}), mutually_exclusive_milestone_args) params_not_mutually_exclusive(args, mutually_exclusive_release_tag_args) - validate_anonymous_search_access! if args[:search].present? super end @@ -128,6 +112,18 @@ def accept_release_tag private + def prepare_finder_params(args) + params = super(args) + params[:not] = params[:not].to_h if params[:not].present? + params[:iids] ||= [params.delete(:iid)].compact if params[:iid] + params[:attempt_project_search_optimizations] = true if params[:search].present? + + prepare_assignee_username_params(params) + prepare_release_tag_params(params) + + params + end + def prepare_release_tag_params(args) release_tag_wildcard = args.delete(:release_tag_wildcard_id) return if release_tag_wildcard.blank? @@ -135,20 +131,13 @@ def prepare_release_tag_params(args) args[:release_tag] ||= release_tag_wildcard end - def mutually_exclusive_release_tag_args - [:release_tag, :release_tag_wildcard_id] - end - def prepare_assignee_username_params(args) args[:assignee_username] = args.delete(:assignee_usernames) if args[:assignee_usernames].present? args[:not][:assignee_username] = args[:not].delete(:assignee_usernames) if args.dig(:not, :assignee_usernames).present? end - def params_not_mutually_exclusive(args, mutually_exclusive_args) - if args.slice(*mutually_exclusive_args).compact.size > 1 - arg_str = mutually_exclusive_args.map { |x| x.to_s.camelize(:lower) }.join(', ') - raise ::Gitlab::Graphql::Errors::ArgumentError, "only one of [#{arg_str}] arguments is allowed at the same time." - end + def mutually_exclusive_release_tag_args + [:release_tag, :release_tag_wildcard_id] end def mutually_exclusive_milestone_args @@ -158,4 +147,20 @@ def mutually_exclusive_milestone_args def mutually_exclusive_assignee_username_args [:assignee_usernames, :assignee_username] end + + def params_not_mutually_exclusive(args, mutually_exclusive_args) + if args.slice(*mutually_exclusive_args).compact.size > 1 + arg_str = mutually_exclusive_args.map { |x| x.to_s.camelize(:lower) }.join(', ') + raise ::Gitlab::Graphql::Errors::ArgumentError, "only one of [#{arg_str}] arguments is allowed at the same time." + end + end + + def resource_parent + # The project could have been loaded in batch by `BatchLoader`. + # At this point we need the `id` of the project to query for issues, so + # make sure it's loaded and not `nil` before continuing. + strong_memoize(:resource_parent) do + object.respond_to?(:sync) ? object.sync : object + end + end end diff --git a/app/graphql/resolvers/concerns/search_arguments.rb b/app/graphql/resolvers/concerns/search_arguments.rb index 7f480f9d0b61..86c7140af0ab 100644 --- a/app/graphql/resolvers/concerns/search_arguments.rb +++ b/app/graphql/resolvers/concerns/search_arguments.rb @@ -7,12 +7,48 @@ module SearchArguments argument :search, GraphQL::Types::String, required: false, description: 'Search query for title or description.' + argument :in, [Types::IssuableSearchableFieldEnum], + required: false, + description: <<~DESC + Specify the fields to perform the search in. + Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument.' + DESC + end + + def ready?(**args) + validate_search_in_params!(args) + validate_anonymous_search_access! + + super end + private + def validate_anonymous_search_access! return if current_user.present? || Feature.disabled?(:disable_anonymous_search, type: :ops) raise ::Gitlab::Graphql::Errors::ArgumentError, "User must be authenticated to include the `search` argument." end + + def validate_search_in_params!(args) + return unless args[:in].present? && args[:search].blank? + + raise Gitlab::Graphql::Errors::ArgumentError, + '`search` should be present when including the `in` argument' + end + + def prepare_finder_params(args) + prepare_search_params(args) + end + + def prepare_search_params(args) + return args unless args[:search].present? + + parent_type = resource_parent.is_a?(Project) ? :project : :group + args[:"attempt_#{parent_type}_search_optimizations"] = true + args[:in] = args[:in].join(',') if args[:in].present? + + args + end end diff --git a/app/graphql/resolvers/work_items_resolver.rb b/app/graphql/resolvers/work_items_resolver.rb index 055984db3cb1..49f9633967d5 100644 --- a/app/graphql/resolvers/work_items_resolver.rb +++ b/app/graphql/resolvers/work_items_resolver.rb @@ -26,24 +26,11 @@ class WorkItemsResolver < BaseResolver required: false def resolve_with_lookahead(**args) - # The project could have been loaded in batch by `BatchLoader`. - # At this point we need the `id` of the project to query for issues, so - # make sure it's loaded and not `nil` before continuing. - parent = object.respond_to?(:sync) ? object.sync : object - return WorkItem.none if parent.nil? || !parent.work_items_feature_flag_enabled? + return WorkItem.none if resource_parent.nil? || !resource_parent.work_items_feature_flag_enabled? - args[:iids] ||= [args.delete(:iid)].compact if args[:iid] - args[:attempt_project_search_optimizations] = true if args[:search].present? + finder = ::WorkItems::WorkItemsFinder.new(current_user, prepare_finder_params(args)) - finder = ::WorkItems::WorkItemsFinder.new(current_user, args) - - Gitlab::Graphql::Loaders::IssuableLoader.new(parent, finder).batching_find_all { |q| apply_lookahead(q) } - end - - def ready?(**args) - validate_anonymous_search_access! if args[:search].present? - - super + Gitlab::Graphql::Loaders::IssuableLoader.new(resource_parent, finder).batching_find_all { |q| apply_lookahead(q) } end private @@ -56,6 +43,22 @@ def unconditional_includes :author ] end + + def prepare_finder_params(args) + params = super(args) + params[:iids] ||= [params.delete(:iid)].compact if params[:iid] + + params + end + + def resource_parent + # The project could have been loaded in batch by `BatchLoader`. + # At this point we need the `id` of the project to query for work items, so + # make sure it's loaded and not `nil` before continuing. + strong_memoize(:resource_parent) do + object.respond_to?(:sync) ? object.sync : object + end + end end end diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 698412c470df..1a6b2ae34390 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -9843,7 +9843,7 @@ four standard [pagination arguments](#connection-pagination-arguments): | <a id="boardepicancestorsiid"></a>`iid` | [`ID`](#id) | IID of the epic, e.g., "1". | | <a id="boardepicancestorsiidstartswith"></a>`iidStartsWith` | [`String`](#string) | Filter epics by IID for autocomplete. | | <a id="boardepicancestorsiids"></a>`iids` | [`[ID!]`](#id) | List of IIDs of epics, e.g., `[1, 2]`. | -| <a id="boardepicancestorsin"></a>`in` | [`[IssuableSearchableField!]`](#issuablesearchablefield) | Specify the fields to perform the search in. Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument. | +| <a id="boardepicancestorsin"></a>`in` | [`[IssuableSearchableField!]`](#issuablesearchablefield) | Specify the fields to perform the search in. Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument.'. | | <a id="boardepicancestorsincludeancestorgroups"></a>`includeAncestorGroups` | [`Boolean`](#boolean) | Include epics from ancestor groups. | | <a id="boardepicancestorsincludedescendantgroups"></a>`includeDescendantGroups` | [`Boolean`](#boolean) | Include epics from descendant groups. | | <a id="boardepicancestorslabelname"></a>`labelName` | [`[String!]`](#string) | Filter epics by labels. | @@ -9881,7 +9881,7 @@ four standard [pagination arguments](#connection-pagination-arguments): | <a id="boardepicchildreniid"></a>`iid` | [`ID`](#id) | IID of the epic, e.g., "1". | | <a id="boardepicchildreniidstartswith"></a>`iidStartsWith` | [`String`](#string) | Filter epics by IID for autocomplete. | | <a id="boardepicchildreniids"></a>`iids` | [`[ID!]`](#id) | List of IIDs of epics, e.g., `[1, 2]`. | -| <a id="boardepicchildrenin"></a>`in` | [`[IssuableSearchableField!]`](#issuablesearchablefield) | Specify the fields to perform the search in. Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument. | +| <a id="boardepicchildrenin"></a>`in` | [`[IssuableSearchableField!]`](#issuablesearchablefield) | Specify the fields to perform the search in. Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument.'. | | <a id="boardepicchildrenincludeancestorgroups"></a>`includeAncestorGroups` | [`Boolean`](#boolean) | Include epics from ancestor groups. | | <a id="boardepicchildrenincludedescendantgroups"></a>`includeDescendantGroups` | [`Boolean`](#boolean) | Include epics from descendant groups. | | <a id="boardepicchildrenlabelname"></a>`labelName` | [`[String!]`](#string) | Filter epics by labels. | @@ -11580,7 +11580,7 @@ four standard [pagination arguments](#connection-pagination-arguments): | <a id="epicancestorsiid"></a>`iid` | [`ID`](#id) | IID of the epic, e.g., "1". | | <a id="epicancestorsiidstartswith"></a>`iidStartsWith` | [`String`](#string) | Filter epics by IID for autocomplete. | | <a id="epicancestorsiids"></a>`iids` | [`[ID!]`](#id) | List of IIDs of epics, e.g., `[1, 2]`. | -| <a id="epicancestorsin"></a>`in` | [`[IssuableSearchableField!]`](#issuablesearchablefield) | Specify the fields to perform the search in. Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument. | +| <a id="epicancestorsin"></a>`in` | [`[IssuableSearchableField!]`](#issuablesearchablefield) | Specify the fields to perform the search in. Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument.'. | | <a id="epicancestorsincludeancestorgroups"></a>`includeAncestorGroups` | [`Boolean`](#boolean) | Include epics from ancestor groups. | | <a id="epicancestorsincludedescendantgroups"></a>`includeDescendantGroups` | [`Boolean`](#boolean) | Include epics from descendant groups. | | <a id="epicancestorslabelname"></a>`labelName` | [`[String!]`](#string) | Filter epics by labels. | @@ -11618,7 +11618,7 @@ four standard [pagination arguments](#connection-pagination-arguments): | <a id="epicchildreniid"></a>`iid` | [`ID`](#id) | IID of the epic, e.g., "1". | | <a id="epicchildreniidstartswith"></a>`iidStartsWith` | [`String`](#string) | Filter epics by IID for autocomplete. | | <a id="epicchildreniids"></a>`iids` | [`[ID!]`](#id) | List of IIDs of epics, e.g., `[1, 2]`. | -| <a id="epicchildrenin"></a>`in` | [`[IssuableSearchableField!]`](#issuablesearchablefield) | Specify the fields to perform the search in. Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument. | +| <a id="epicchildrenin"></a>`in` | [`[IssuableSearchableField!]`](#issuablesearchablefield) | Specify the fields to perform the search in. Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument.'. | | <a id="epicchildrenincludeancestorgroups"></a>`includeAncestorGroups` | [`Boolean`](#boolean) | Include epics from ancestor groups. | | <a id="epicchildrenincludedescendantgroups"></a>`includeDescendantGroups` | [`Boolean`](#boolean) | Include epics from descendant groups. | | <a id="epicchildrenlabelname"></a>`labelName` | [`[String!]`](#string) | Filter epics by labels. | @@ -12468,7 +12468,7 @@ Returns [`Epic`](#epic). | <a id="groupepiciid"></a>`iid` | [`ID`](#id) | IID of the epic, e.g., "1". | | <a id="groupepiciidstartswith"></a>`iidStartsWith` | [`String`](#string) | Filter epics by IID for autocomplete. | | <a id="groupepiciids"></a>`iids` | [`[ID!]`](#id) | List of IIDs of epics, e.g., `[1, 2]`. | -| <a id="groupepicin"></a>`in` | [`[IssuableSearchableField!]`](#issuablesearchablefield) | Specify the fields to perform the search in. Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument. | +| <a id="groupepicin"></a>`in` | [`[IssuableSearchableField!]`](#issuablesearchablefield) | Specify the fields to perform the search in. Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument.'. | | <a id="groupepicincludeancestorgroups"></a>`includeAncestorGroups` | [`Boolean`](#boolean) | Include epics from ancestor groups. | | <a id="groupepicincludedescendantgroups"></a>`includeDescendantGroups` | [`Boolean`](#boolean) | Include epics from descendant groups. | | <a id="groupepiclabelname"></a>`labelName` | [`[String!]`](#string) | Filter epics by labels. | @@ -12518,7 +12518,7 @@ four standard [pagination arguments](#connection-pagination-arguments): | <a id="groupepicsiid"></a>`iid` | [`ID`](#id) | IID of the epic, e.g., "1". | | <a id="groupepicsiidstartswith"></a>`iidStartsWith` | [`String`](#string) | Filter epics by IID for autocomplete. | | <a id="groupepicsiids"></a>`iids` | [`[ID!]`](#id) | List of IIDs of epics, e.g., `[1, 2]`. | -| <a id="groupepicsin"></a>`in` | [`[IssuableSearchableField!]`](#issuablesearchablefield) | Specify the fields to perform the search in. Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument. | +| <a id="groupepicsin"></a>`in` | [`[IssuableSearchableField!]`](#issuablesearchablefield) | Specify the fields to perform the search in. Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument.'. | | <a id="groupepicsincludeancestorgroups"></a>`includeAncestorGroups` | [`Boolean`](#boolean) | Include epics from ancestor groups. | | <a id="groupepicsincludedescendantgroups"></a>`includeDescendantGroups` | [`Boolean`](#boolean) | Include epics from descendant groups. | | <a id="groupepicslabelname"></a>`labelName` | [`[String!]`](#string) | Filter epics by labels. | @@ -12580,6 +12580,7 @@ four standard [pagination arguments](#connection-pagination-arguments): | <a id="groupissuesepicid"></a>`epicId` | [`String`](#string) | ID of an epic associated with the issues, "none" and "any" values are supported. | | <a id="groupissuesiid"></a>`iid` | [`String`](#string) | IID of the issue. For example, "1". | | <a id="groupissuesiids"></a>`iids` | [`[String!]`](#string) | List of IIDs of issues. For example, `["1", "2"]`. | +| <a id="groupissuesin"></a>`in` | [`[IssuableSearchableField!]`](#issuablesearchablefield) | Specify the fields to perform the search in. Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument.'. | | <a id="groupissuesincludearchived"></a>`includeArchived` | [`Boolean`](#boolean) | Return issues from archived projects. | | <a id="groupissuesincludesubepics"></a>`includeSubepics` | [`Boolean`](#boolean) | Whether to include subepics when filtering issues by epicId. | | <a id="groupissuesincludesubgroups"></a>`includeSubgroups` | [`Boolean`](#boolean) | Include issues belonging to subgroups. | @@ -16072,6 +16073,7 @@ Returns [`Issue`](#issue). | <a id="projectissueepicid"></a>`epicId` | [`String`](#string) | ID of an epic associated with the issues, "none" and "any" values are supported. | | <a id="projectissueiid"></a>`iid` | [`String`](#string) | IID of the issue. For example, "1". | | <a id="projectissueiids"></a>`iids` | [`[String!]`](#string) | List of IIDs of issues. For example, `["1", "2"]`. | +| <a id="projectissuein"></a>`in` | [`[IssuableSearchableField!]`](#issuablesearchablefield) | Specify the fields to perform the search in. Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument.'. | | <a id="projectissueincludesubepics"></a>`includeSubepics` | [`Boolean`](#boolean) | Whether to include subepics when filtering issues by epicId. | | <a id="projectissueiterationid"></a>`iterationId` | [`[ID]`](#id) | List of iteration Global IDs applied to the issue. | | <a id="projectissueiterationwildcardid"></a>`iterationWildcardId` | [`IterationWildcardId`](#iterationwildcardid) | Filter by iteration ID wildcard. | @@ -16113,6 +16115,7 @@ Returns [`IssueStatusCountsType`](#issuestatuscountstype). | <a id="projectissuestatuscountscrmorganizationid"></a>`crmOrganizationId` | [`String`](#string) | ID of an organization assigned to the issues. | | <a id="projectissuestatuscountsiid"></a>`iid` | [`String`](#string) | IID of the issue. For example, "1". | | <a id="projectissuestatuscountsiids"></a>`iids` | [`[String!]`](#string) | List of IIDs of issues. For example, `["1", "2"]`. | +| <a id="projectissuestatuscountsin"></a>`in` | [`[IssuableSearchableField!]`](#issuablesearchablefield) | Specify the fields to perform the search in. Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument.'. | | <a id="projectissuestatuscountslabelname"></a>`labelName` | [`[String]`](#string) | Labels applied to this issue. | | <a id="projectissuestatuscountsmilestonetitle"></a>`milestoneTitle` | [`[String]`](#string) | Milestone applied to this issue. | | <a id="projectissuestatuscountsmilestonewildcardid"></a>`milestoneWildcardId` | [`MilestoneWildcardId`](#milestonewildcardid) | Filter issues by milestone ID wildcard. | @@ -16153,6 +16156,7 @@ four standard [pagination arguments](#connection-pagination-arguments): | <a id="projectissuesepicid"></a>`epicId` | [`String`](#string) | ID of an epic associated with the issues, "none" and "any" values are supported. | | <a id="projectissuesiid"></a>`iid` | [`String`](#string) | IID of the issue. For example, "1". | | <a id="projectissuesiids"></a>`iids` | [`[String!]`](#string) | List of IIDs of issues. For example, `["1", "2"]`. | +| <a id="projectissuesin"></a>`in` | [`[IssuableSearchableField!]`](#issuablesearchablefield) | Specify the fields to perform the search in. Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument.'. | | <a id="projectissuesincludesubepics"></a>`includeSubepics` | [`Boolean`](#boolean) | Whether to include subepics when filtering issues by epicId. | | <a id="projectissuesiterationid"></a>`iterationId` | [`[ID]`](#id) | List of iteration Global IDs applied to the issue. | | <a id="projectissuesiterationwildcardid"></a>`iterationWildcardId` | [`IterationWildcardId`](#iterationwildcardid) | Filter by iteration ID wildcard. | @@ -16730,6 +16734,7 @@ four standard [pagination arguments](#connection-pagination-arguments): | ---- | ---- | ----------- | | <a id="projectworkitemsiid"></a>`iid` | [`String`](#string) | IID of the issue. For example, "1". | | <a id="projectworkitemsiids"></a>`iids` | [`[String!]`](#string) | List of IIDs of work items. For example, `["1", "2"]`. | +| <a id="projectworkitemsin"></a>`in` | [`[IssuableSearchableField!]`](#issuablesearchablefield) | Specify the fields to perform the search in. Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument.'. | | <a id="projectworkitemssearch"></a>`search` | [`String`](#string) | Search query for title or description. | | <a id="projectworkitemssort"></a>`sort` | [`WorkItemSort`](#workitemsort) | Sort work items by this criteria. | | <a id="projectworkitemsstate"></a>`state` | [`IssuableState`](#issuablestate) | Current state of this work item. | diff --git a/ee/app/graphql/resolvers/epics_resolver.rb b/ee/app/graphql/resolvers/epics_resolver.rb index 66c6706534ea..545c927dbe90 100644 --- a/ee/app/graphql/resolvers/epics_resolver.rb +++ b/ee/app/graphql/resolvers/epics_resolver.rb @@ -18,10 +18,6 @@ class EpicsResolver < BaseResolver required: false, description: 'Filter epics by state.' - argument :in, [Types::IssuableSearchableFieldEnum], - required: false, - description: 'Specify the fields to perform the search in. Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument.' - argument :sort, Types::EpicSortEnum, required: false, description: 'List epics by sort order.' @@ -86,8 +82,6 @@ class EpicsResolver < BaseResolver def ready?(**args) validate_timeframe_params!(args) validate_starts_with_iid!(args) - validate_search_in_params!(args) - validate_anonymous_search_access! if args[:search].present? super(**args) end @@ -98,7 +92,7 @@ def resolve_with_lookahead(**args) return [] unless resolver_object.present? return [] unless epic_feature_enabled? - find_epics(transform_args(args)) + find_epics(prepare_finder_params(args)) end private @@ -130,14 +124,19 @@ def epic_feature_enabled? end def transform_args(args) - transformed = args.dup - transformed[:group_id] = group - transformed[:iids] ||= [args[:iid]].compact - transformed[:in] = args[:in].join(',') if args[:in].present? + transformed = args.dup + transformed[:group_id] = group + transformed[:iids] ||= [args[:iid]].compact transformed.merge(transform_timeframe_parameters(args)).merge(relative_param) end + def prepare_finder_params(args) + params = transform_args(args) + + super(params) + end + def relative_param return {} unless parent @@ -159,6 +158,10 @@ def group parent.group end + def resource_parent + strong_memoize(:resource_parent) { group } + end + # If we're querying for multiple iids and selecting issues, then ideally # we want to batch the epic and issue queries into one to reduce N+1 and memory. # https://gitlab.com/gitlab-org/gitlab/issues/11841 @@ -183,12 +186,5 @@ def validate_starts_with_iid!(args) raise Gitlab::Graphql::Errors::ArgumentError, 'Invalid `iidStartsWith` query' end end - - def validate_search_in_params!(args) - return unless args[:in].present? && args[:search].blank? - - raise Gitlab::Graphql::Errors::ArgumentError, - '`search` should be present when including the `in` argument' - end end end diff --git a/ee/spec/graphql/resolvers/epics_resolver_spec.rb b/ee/spec/graphql/resolvers/epics_resolver_spec.rb index 5c0d01ec2445..25fb495c4ff7 100644 --- a/ee/spec/graphql/resolvers/epics_resolver_spec.rb +++ b/ee/spec/graphql/resolvers/epics_resolver_spec.rb @@ -99,83 +99,15 @@ end end - context 'with search' do - let!(:epic3) { create(:epic, group: group, title: 'third', description: 'text 2') } - - it 'filters epics by title' do - epics = resolve_epics(search: 'created') - - expect(epics).to match_array([epic1, epic2]) - end - - it 'filters epics by description' do - epics = resolve_epics(search: 'text') - - expect(epics).to match_array([epic2, epic3]) - end - - context 'with in param' do - it 'generates an error if param search is missing' do - error_message = "`search` should be present when including the `in` argument" - - expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, error_message) do - resolve_epics(in: ['title']) - end - end - - it 'filters epics by description only' do - epics_with_text = resolve_epics(search: 'text', in: ['description']) - epics_with_created = resolve_epics(search: 'created', in: ['description']) - - expect(epics_with_created).to be_empty - expect(epics_with_text).to match_array([epic2, epic3]) - end - - it 'filters epics by title only' do - epics_with_text = resolve_epics(search: 'text', in: ['title']) - epics_with_created = resolve_epics(search: 'created', in: ['title']) - - expect(epics_with_created).to match_array([epic1, epic2]) - expect(epics_with_text).to be_empty - end - - it 'filters epics by title and description' do - epic4 = create(:epic, group: group, title: 'fourth text', description: ['description']) - epics = resolve_epics(search: 'text', in: %w(title description)) - - expect(epics).to match_array([epic2, epic3, epic4]) - end - end - - context 'with anonymous user' do - let_it_be(:current_user) { nil } - - context 'with disable_anonymous_search enabled' do - before do - stub_feature_flags(disable_anonymous_search: true) - end - - it 'returns an error' do - error_message = "User must be authenticated to include the `search` argument." - - expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, error_message) do - resolve_epics(search: 'created') - end - end - end - - context 'with disable_anonymous_search disabled' do - before do - stub_feature_flags(disable_anonymous_search: false) - end - - it 'filters epics by search term' do - epics = resolve_epics(search: 'created') - - expect(epics).to match_array([epic1, epic2]) - end - end - end + it_behaves_like 'graphql query for searching issuables' do + let_it_be(:parent) { group } + let_it_be(:issuable1) { epic1 } + let_it_be(:issuable2) { epic2 } + let_it_be(:issuable3) { create(:epic, group: group, title: 'third', description: 'text 2') } + let_it_be(:issuable4) { create(:epic, group: group) } + + let_it_be(:finder_class) { EpicsFinder } + let_it_be(:optimization_param) { :attempt_group_search_optimizations } end context 'with author_username' do diff --git a/ee/spec/requests/api/graphql/group/epics_spec.rb b/ee/spec/requests/api/graphql/group/epics_spec.rb index 42042099535d..69ed0c829413 100644 --- a/ee/spec/requests/api/graphql/group/epics_spec.rb +++ b/ee/spec/requests/api/graphql/group/epics_spec.rb @@ -262,16 +262,10 @@ def global_ids(*epics) end end - context 'filter' do - context 'with search params' do - it 'returns only matching epics' do - filter_params = { search: 'bar', in: [:DESCRIPTION] } - graphql_query = query(filter_params) - - post_graphql(graphql_query, current_user: user) - - expect_array_response([epic2.to_global_id.to_s]) - end + context 'with search params' do + it_behaves_like 'query with a search term' do + let(:issuable_data) { epics_data } + let(:issuable) { epic2 } end end end diff --git a/spec/graphql/resolvers/issues_resolver_spec.rb b/spec/graphql/resolvers/issues_resolver_spec.rb index 89e45810033f..a74b2a3f18cb 100644 --- a/spec/graphql/resolvers/issues_resolver_spec.rb +++ b/spec/graphql/resolvers/issues_resolver_spec.rb @@ -311,49 +311,15 @@ end context 'when searching issues' do - it 'returns correct issues' do - expect(resolve_issues(search: 'foo')).to contain_exactly(issue2) - end - - it 'uses project search optimization' do - expected_arguments = a_hash_including( - search: 'foo', - attempt_project_search_optimizations: true - ) - expect(IssuesFinder).to receive(:new).with(anything, expected_arguments).and_call_original - - resolve_issues(search: 'foo') - end - - context 'with anonymous user' do - let_it_be(:public_project) { create(:project, :public) } - let_it_be(:public_issue) { create(:issue, project: public_project, title: 'Test issue') } - - context 'with disable_anonymous_search enabled' do - before do - stub_feature_flags(disable_anonymous_search: true) - end - - it 'generates an error' do - error_message = "User must be authenticated to include the `search` argument." - - expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, error_message) do - resolve(described_class, obj: public_project, args: { search: 'test' }, ctx: { current_user: nil }) - end - end - end - - context 'with disable_anonymous_search disabled' do - before do - stub_feature_flags(disable_anonymous_search: false) - end - - it 'returns correct issues' do - expect( - resolve(described_class, obj: public_project, args: { search: 'test' }, ctx: { current_user: nil }) - ).to contain_exactly(public_issue) - end - end + it_behaves_like 'graphql query for searching issuables' do + let_it_be(:parent) { project } + let_it_be(:issuable1) { create(:issue, project: project, title: 'first created') } + let_it_be(:issuable2) { create(:issue, project: project, title: 'second created', description: 'text 1') } + let_it_be(:issuable3) { create(:issue, project: project, title: 'third', description: 'text 2') } + let_it_be(:issuable4) { create(:issue, project: project) } + + let_it_be(:finder_class) { IssuesFinder } + let_it_be(:optimization_param) { :attempt_project_search_optimizations } end end diff --git a/spec/graphql/resolvers/work_items_resolver_spec.rb b/spec/graphql/resolvers/work_items_resolver_spec.rb index 29eac0ab46ed..ef7cc0daa0c2 100644 --- a/spec/graphql/resolvers/work_items_resolver_spec.rb +++ b/spec/graphql/resolvers/work_items_resolver_spec.rb @@ -52,49 +52,15 @@ end context 'when searching items' do - it 'returns correct items' do - expect(resolve_items(search: 'foo')).to contain_exactly(item2) - end - - it 'uses project search optimization' do - expected_arguments = a_hash_including( - search: 'foo', - attempt_project_search_optimizations: true - ) - expect(::WorkItems::WorkItemsFinder).to receive(:new).with(anything, expected_arguments).and_call_original - - resolve_items(search: 'foo') - end - - context 'with anonymous user' do - let_it_be(:public_project) { create(:project, :public) } - let_it_be(:public_item) { create(:work_item, project: public_project, title: 'Test item') } - - context 'with disable_anonymous_search enabled' do - before do - stub_feature_flags(disable_anonymous_search: true) - end - - it 'generates an error' do - error_message = "User must be authenticated to include the `search` argument." - - expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, error_message) do - resolve(described_class, obj: public_project, args: { search: 'test' }, ctx: { current_user: nil }) - end - end - end - - context 'with disable_anonymous_search disabled' do - before do - stub_feature_flags(disable_anonymous_search: false) - end - - it 'returns correct items' do - expect( - resolve(described_class, obj: public_project, args: { search: 'test' }, ctx: { current_user: nil }) - ).to contain_exactly(public_item) - end - end + it_behaves_like 'graphql query for searching issuables' do + let_it_be(:parent) { project } + let_it_be(:issuable1) { create(:work_item, project: project, title: 'first created') } + let_it_be(:issuable2) { create(:work_item, project: project, title: 'second created', description: 'text 1') } + let_it_be(:issuable3) { create(:work_item, project: project, title: 'third', description: 'text 2') } + let_it_be(:issuable4) { create(:work_item, project: project) } + + let_it_be(:finder_class) { ::WorkItems::WorkItemsFinder } + let_it_be(:optimization_param) { :attempt_project_search_optimizations } end end diff --git a/spec/requests/api/graphql/project/issues_spec.rb b/spec/requests/api/graphql/project/issues_spec.rb index 596e023a027d..d0ed42123491 100644 --- a/spec/requests/api/graphql/project/issues_spec.rb +++ b/spec/requests/api/graphql/project/issues_spec.rb @@ -27,14 +27,6 @@ QUERY end - let(:query) do - graphql_query_for( - 'project', - { 'fullPath' => project.full_path }, - query_graphql_field('issues', issue_filter_params, fields) - ) - end - it_behaves_like 'a working graphql query' do before do post_graphql(query, current_user: current_user) @@ -89,6 +81,14 @@ end end + context 'when filtering by search' do + it_behaves_like 'query with a search term' do + let(:issuable_data) { issues_data } + let(:user) { current_user } + let_it_be(:issuable) { create(:issue, project: project, description: 'bar') } + end + end + context 'when limiting the number of results' do let(:query) do <<~GQL @@ -679,4 +679,12 @@ def execute_query def issues_ids graphql_dig_at(issues_data, :node, :id) end + + def query(params = issue_filter_params) + graphql_query_for( + 'project', + { 'fullPath' => project.full_path }, + query_graphql_field('issues', params, fields) + ) + end end diff --git a/spec/requests/api/graphql/project/work_items_spec.rb b/spec/requests/api/graphql/project/work_items_spec.rb index 6ef28392b8b1..1dd0046f782c 100644 --- a/spec/requests/api/graphql/project/work_items_spec.rb +++ b/spec/requests/api/graphql/project/work_items_spec.rb @@ -27,14 +27,6 @@ QUERY end - let(:query) do - graphql_query_for( - 'project', - { 'fullPath' => project.full_path }, - query_graphql_field('workItems', item_filter_params, fields) - ) - end - it_behaves_like 'a working graphql query' do before do post_graphql(query, current_user: current_user) @@ -83,6 +75,14 @@ end end + context 'when filtering by search' do + it_behaves_like 'query with a search term' do + let(:issuable_data) { items_data } + let(:user) { current_user } + let_it_be(:issuable) { create(:work_item, project: project, description: 'bar') } + end + end + describe 'sorting and pagination' do let(:data_path) { [:project, :work_items] } @@ -118,4 +118,12 @@ def pagination_query(params) def item_ids graphql_dig_at(items_data, :node, :id) end + + def query(params = item_filter_params) + graphql_query_for( + 'project', + { 'fullPath' => project.full_path }, + query_graphql_field('workItems', params, fields) + ) + end end diff --git a/spec/support/shared_examples/graphql/resolvers/issuable_resolvers_shared_examples.rb b/spec/support/shared_examples/graphql/resolvers/issuable_resolvers_shared_examples.rb new file mode 100644 index 000000000000..feaa8070090e --- /dev/null +++ b/spec/support/shared_examples/graphql/resolvers/issuable_resolvers_shared_examples.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +# Requires `parent`, issuable1`, `issuable2`, `issuable3`, `issuable4`, +# `finder_class` and `optimization_param` bindings. +RSpec.shared_examples 'graphql query for searching issuables' do + it 'uses search optimization' do + expected_arguments = a_hash_including( + search: 'text', + optimization_param => true + ) + expect(finder_class).to receive(:new).with(anything, expected_arguments).and_call_original + + resolve_issuables(search: 'text') + end + + it 'filters issuables by title' do + issuables = resolve_issuables(search: 'created') + + expect(issuables).to contain_exactly(issuable1, issuable2) + end + + it 'filters issuables by description' do + issuables = resolve_issuables(search: 'text') + + expect(issuables).to contain_exactly(issuable2, issuable3) + end + + context 'with in param' do + it 'generates an error if param search is missing' do + error_message = "`search` should be present when including the `in` argument" + + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, error_message) do + resolve_issuables(in: ['title']) + end + end + + it 'filters issuables by title and description' do + issuable4.update!(title: 'fourth text') + issuables = resolve_issuables(search: 'text', in: %w[title description]) + + expect(issuables).to contain_exactly(issuable2, issuable3, issuable4) + end + + it 'filters issuables by description only' do + with_text = resolve_issuables(search: 'text', in: ['description']) + with_created = resolve_issuables(search: 'created', in: ['description']) + + expect(with_created).to be_empty + expect(with_text).to contain_exactly(issuable2, issuable3) + end + + it 'filters issuables by title only' do + with_text = resolve_issuables(search: 'text', in: ['title']) + with_created = resolve_issuables(search: 'created', in: ['title']) + + expect(with_created).to contain_exactly(issuable1, issuable2) + expect(with_text).to be_empty + end + end + + context 'with anonymous user' do + let_it_be(:current_user) { nil } + + context 'with disable_anonymous_search as `true`' do + before do + stub_feature_flags(disable_anonymous_search: true) + end + + it 'returns an error' do + error_message = "User must be authenticated to include the `search` argument." + + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, error_message) do + resolve_issuables(search: 'created') + end + end + end + + context 'with disable_anonymous_search as `false`' do + before do + stub_feature_flags(disable_anonymous_search: false) + parent.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC) + end + + it 'filters issuables by search term' do + issuables = resolve_issuables(search: 'created') + + expect(issuables).to contain_exactly(issuable1, issuable2) + end + end + end + + def resolve_issuables(args = {}, obj = parent, context = { current_user: current_user }) + resolve(described_class, obj: obj, args: args, ctx: context, arg_style: :internal) + end +end diff --git a/spec/support/shared_examples/requests/api/graphql/issuable_search_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/issuable_search_shared_examples.rb new file mode 100644 index 000000000000..22805cf7aedf --- /dev/null +++ b/spec/support/shared_examples/requests/api/graphql/issuable_search_shared_examples.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# Requires `query(params)` , `user`, `issuable_data` and `issuable` bindings +RSpec.shared_examples 'query with a search term' do + it 'returns only matching issuables' do + filter_params = { search: 'bar', in: [:DESCRIPTION] } + graphql_query = query(filter_params) + + post_graphql(graphql_query, current_user: user) + ids = graphql_dig_at(issuable_data, :node, :id) + + expect(ids).to contain_exactly(issuable.to_global_id.to_s) + end +end -- GitLab