Browse Source

Feature: Mobile - Add a generic search back end for GraphQL.

Martin Gruner 2 years ago
parent
commit
efc58f8b52

+ 12 - 0
app/frontend/shared/graphql/types.ts

@@ -450,6 +450,8 @@ export type Queries = {
   node?: Maybe<Node>;
   /** Fetches a list of objects given a list of IDs. */
   nodes: Array<Maybe<Node>>;
+  /** Generic object search */
+  search: Array<SearchResult>;
   /** The sessionId of the currently authenticated user. */
   sessionId: Scalars['String'];
   /** Fetch a ticket by ID */
@@ -496,6 +498,13 @@ export type QueriesNodesArgs = {
 };
 
 
+/** All available queries */
+export type QueriesSearchArgs = {
+  limit?: InputMaybe<Scalars['Int']>;
+  query: Scalars['String'];
+};
+
+
 /** All available queries */
 export type QueriesTicketArgs = {
   ticket: TicketLocatorInput;
@@ -539,6 +548,9 @@ export type QueriesTranslationsArgs = {
   locale: Scalars['String'];
 };
 
+/** Objects found by search */
+export type SearchResult = Organization | Ticket | User;
+
 /** All available subscriptions */
 export type Subscriptions = {
   __typename?: 'Subscriptions';

+ 71 - 0
app/graphql/gql/queries/search.rb

@@ -0,0 +1,71 @@
+# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+module Gql::Queries
+  class Search < BaseQuery
+
+    description 'Generic object search'
+
+    argument :query, String, required: true, description: 'What to search for'
+    argument :limit, Integer, required: false, description: 'How many entries to find at maximum per model'
+
+    type [Gql::Types::SearchResultType, { null: false }], null: false
+
+    def resolve(query:, limit: 10)
+      if SearchIndexBackend.enabled?
+        # Performance optimization: some models may allow combining their Elasticsearch queries into one.
+        result_by_model = combined_backend_search(query: query, limit: limit)
+
+        # Other models require dedicated handling, e.g. for permission checks.
+        result_by_model.merge!(models(direct_search_index: false).index_with do |model|
+          model_search(model: model, query: query, limit: limit)
+        end)
+
+        # Finally, sort by object priority.
+        models.map do |model|
+          result_by_model[model]
+        end.flatten
+      else
+        models.map do |model|
+          model_search(model: model, query: query, limit: limit)
+        end.flatten
+      end
+    end
+
+    private
+
+    # Perform a direct, cross-module Elasticsearch query and map the results by class.
+    def combined_backend_search(query:, limit:)
+      result_by_model = {}
+      models_with_direct_search_index = models(direct_search_index: true).map(&:to_s)
+      if models_with_direct_search_index
+        SearchIndexBackend.search(query, models_with_direct_search_index, limit: limit).each do |item|
+          klass = "::#{item[:type]}".constantize
+          record = klass.lookup(id: item[:id])
+          (result_by_model[klass] ||= []).push(record) if record
+        end
+      end
+      result_by_model
+    end
+
+    # Call the model specific search, which will query Elasticsearch if available,
+    #   or the Database otherwise.
+    def model_search(model:, query:, limit:)
+      model.search({ query: query, limit: limit, current_user: context.current_user })
+    end
+
+    SEARCHABLE_MODELS = [::Ticket, ::User, ::Organization].freeze
+
+    # Get a prioritized list of searchable models
+    def models(direct_search_index: nil)
+      SEARCHABLE_MODELS.select do |model|
+        prefs = model.search_preferences(context.current_user)
+        next false if !prefs
+        next false if direct_search_index.present? && !prefs[:direct_search_index] != direct_search_index
+
+        true
+      end.sort_by do |model|
+        model.search_preferences(context.current_user)[:prio]
+      end
+    end
+  end
+end

+ 8 - 0
app/graphql/gql/types/search_result_type.rb

@@ -0,0 +1,8 @@
+# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+module Gql::Types
+  class SearchResultType < BaseUnion
+    description 'Objects found by search'
+    possible_types Gql::Types::TicketType, Gql::Types::UserType, Gql::Types::OrganizationType
+  end
+end

+ 95 - 0
spec/graphql/gql/queries/search_spec.rb

@@ -0,0 +1,95 @@
+# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+require 'rails_helper'
+
+RSpec.describe Gql::Queries::Search, type: :graphql do
+
+  context 'when performing generic searches' do
+    let(:group)        { create(:group) }
+    let(:organization) { create(:organization, name: search) }
+    let(:agent)        { create(:agent, firstname: search, groups: [ticket.group]) }
+    let!(:ticket)     do
+      create(:ticket, title: search, organization: organization).tap do |ticket|
+        # Article required to find ticket via SQL
+        create(:ticket_article, ticket: ticket)
+      end
+    end
+    let(:search)    { SecureRandom.uuid }
+    let(:query)     do
+      <<~QUERY
+        query search($query: String!) {
+          search(query: $query) {
+            ... on Ticket {
+              __typename
+              number
+              title
+            }
+            ... on User {
+              __typename
+              firstname
+              lastname
+            }
+            ... on Organization {
+              __typename
+              name
+            }
+          }
+        }
+      QUERY
+    end
+    let(:variables) { { query: search } }
+    let(:es_setup) do
+      Setting.set('es_url', nil)
+    end
+
+    before do
+      es_setup
+      gql.execute(query, variables: variables)
+    end
+
+    shared_examples 'test search query' do
+
+      context 'with an agent', authenticated_as: :agent do
+        let(:expected_result) do
+          [
+            { '__typename' => 'Organization', 'name' => organization.name },
+            { '__typename' => 'User', 'firstname' => agent.firstname, 'lastname' => agent.lastname },
+            { '__typename' => 'Ticket', 'number' => ticket.number, 'title' => ticket.title },
+          ]
+        end
+
+        it 'finds expected objects' do
+          expect(gql.result.data).to eq(expected_result)
+        end
+      end
+
+      context 'with a customer', authenticated_as: :customer do
+        let(:customer) { create(:customer, firstname: search, organization: organization) }
+        let(:expected_result) do
+          [
+            { '__typename' => 'Organization', 'name' => organization.name },
+          ]
+        end
+
+        it 'finds only objects available to the customer' do
+          expect(gql.result.data).to eq(expected_result)
+        end
+      end
+    end
+
+    context 'without search index' do
+      include_examples 'test search query'
+    end
+
+    context 'with search index', searchindex: true do
+      let(:es_setup) do
+        configure_elasticsearch
+        rebuild_searchindex
+      end
+
+      include_examples 'test search query'
+    end
+
+    it_behaves_like 'graphql responds with error if unauthenticated'
+  end
+end