Browse Source

Feature: Use graphql-batch for batch loading records from the DB.

Thorsten Eckel 3 years ago
parent
commit
ba5871cf6c

+ 1 - 1
.rubocop/cop/zammad/detect_translatable_string.rb

@@ -28,7 +28,7 @@ module RuboCop
           __ translate
           include? eql? parse
           debug info warn error fatal unknown log log_error
-          field argument description value
+          field argument description value has_one belongs_to
         ].freeze
 
         def on_send(node)

+ 72 - 3
app/frontend/apps/mobile/graphql/api.ts

@@ -18,8 +18,74 @@ export const ObjectAttributeValuesFragmentDoc = gql`
   value
 }
     `;
+export const TicketsByIdDocument = gql`
+    query ticketsById($ticketId: ID!, $withArticles: Boolean = false, $withObjectAttributes: Boolean = false) {
+  ticketById(ticketId: $ticketId) {
+    id
+    number
+    title
+    createdAt
+    updatedAt
+    owner {
+      firstname
+      lastname
+    }
+    customer {
+      firstname
+      lastname
+    }
+    organization {
+      name
+    }
+    state {
+      name
+      stateType {
+        name
+      }
+    }
+    group {
+      name
+    }
+    priority {
+      name
+    }
+    articles @include(if: $withArticles) {
+      edges {
+        node {
+          subject
+        }
+      }
+    }
+    objectAttributeValues @include(if: $withObjectAttributes) {
+      ...objectAttributeValues
+    }
+  }
+}
+    ${ObjectAttributeValuesFragmentDoc}`;
+
+/**
+ * __useTicketsByIdQuery__
+ *
+ * To run a query within a Vue component, call `useTicketsByIdQuery` and pass it any options that fit your needs.
+ * When your component renders, `useTicketsByIdQuery` returns an object from Apollo Client that contains result, loading and error properties
+ * you can use to render your UI.
+ *
+ * @param variables that will be passed into the query
+ * @param options that will be passed into the query, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/query.html#options;
+ *
+ * @example
+ * const { result, loading, error } = useTicketsByIdQuery({
+ *   ticketId: // value for 'ticketId'
+ *   withArticles: // value for 'withArticles'
+ *   withObjectAttributes: // value for 'withObjectAttributes'
+ * });
+ */
+export function useTicketsByIdQuery(variables: Types.TicketsByIdQueryVariables | VueCompositionApi.Ref<Types.TicketsByIdQueryVariables> | ReactiveFunction<Types.TicketsByIdQueryVariables>, options: VueApolloComposable.UseQueryOptions<Types.TicketsByIdQuery, Types.TicketsByIdQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<Types.TicketsByIdQuery, Types.TicketsByIdQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<Types.TicketsByIdQuery, Types.TicketsByIdQueryVariables>> = {}) {
+  return VueApolloComposable.useQuery<Types.TicketsByIdQuery, Types.TicketsByIdQueryVariables>(TicketsByIdDocument, variables, options);
+}
+export type TicketsByIdQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<Types.TicketsByIdQuery, Types.TicketsByIdQueryVariables>;
 export const TicketsByOverviewDocument = gql`
-    query ticketsByOverview($overviewId: ID!, $orderBy: TicketOrderBy, $orderDirection: OrderDirection, $cursor: String, $pageSize: Int = 10) {
+    query ticketsByOverview($overviewId: ID!, $orderBy: TicketOrderBy, $orderDirection: OrderDirection, $cursor: String, $pageSize: Int = 10, $withObjectAttributes: Boolean = false) {
   ticketsByOverview(
     overviewId: $overviewId
     orderBy: $orderBy
@@ -48,7 +114,9 @@ export const TicketsByOverviewDocument = gql`
         }
         state {
           name
-          stateTypeName
+          stateType {
+            name
+          }
         }
         group {
           name
@@ -56,7 +124,7 @@ export const TicketsByOverviewDocument = gql`
         priority {
           name
         }
-        objectAttributeValues {
+        objectAttributeValues @include(if: $withObjectAttributes) {
           ...objectAttributeValues
         }
       }
@@ -87,6 +155,7 @@ export const TicketsByOverviewDocument = gql`
  *   orderDirection: // value for 'orderDirection'
  *   cursor: // value for 'cursor'
  *   pageSize: // value for 'pageSize'
+ *   withObjectAttributes: // value for 'withObjectAttributes'
  * });
  */
 export function useTicketsByOverviewQuery(variables: Types.TicketsByOverviewQueryVariables | VueCompositionApi.Ref<Types.TicketsByOverviewQueryVariables> | ReactiveFunction<Types.TicketsByOverviewQueryVariables>, options: VueApolloComposable.UseQueryOptions<Types.TicketsByOverviewQuery, Types.TicketsByOverviewQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<Types.TicketsByOverviewQuery, Types.TicketsByOverviewQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<Types.TicketsByOverviewQuery, Types.TicketsByOverviewQueryVariables>> = {}) {

+ 12 - 0
app/frontend/apps/mobile/graphql/fragments/objectAttributeValues.graphql

@@ -0,0 +1,12 @@
+fragment objectAttributeValues on ObjectAttributeValue {
+  attribute {
+    name
+    display
+    dataType
+    dataOption
+    screens
+    editable
+    active
+  }
+  value
+}

+ 46 - 0
app/frontend/apps/mobile/graphql/queries/ticketById.graphql

@@ -0,0 +1,46 @@
+query ticketsById(
+  $ticketId: ID!
+  $withArticles: Boolean = false
+  $withObjectAttributes: Boolean = false
+) {
+  ticketById(ticketId: $ticketId) {
+    id
+    number
+    title
+    createdAt
+    updatedAt
+    owner {
+      firstname
+      lastname
+    }
+    customer {
+      firstname
+      lastname
+    }
+    organization {
+      name
+    }
+    state {
+      name
+      stateType {
+        name
+      }
+    }
+    group {
+      name
+    }
+    priority {
+      name
+    }
+    articles @include(if: $withArticles) {
+      edges {
+        node {
+          subject
+        }
+      }
+    }
+    objectAttributeValues @include(if: $withObjectAttributes) {
+      ...objectAttributeValues
+    }
+  }
+}

+ 5 - 15
app/frontend/apps/mobile/graphql/queries/ticketsByOverviews.graphql → app/frontend/apps/mobile/graphql/queries/ticketsByOverview.graphql

@@ -4,6 +4,7 @@ query ticketsByOverview(
   $orderDirection: OrderDirection
   $cursor: String
   $pageSize: Int = 10
+  $withObjectAttributes: Boolean = false
 ) {
   ticketsByOverview(
     overviewId: $overviewId
@@ -33,7 +34,9 @@ query ticketsByOverview(
         }
         state {
           name
-          stateTypeName
+          stateType {
+            name
+          }
         }
         group {
           name
@@ -41,7 +44,7 @@ query ticketsByOverview(
         priority {
           name
         }
-        objectAttributeValues {
+        objectAttributeValues @include(if: $withObjectAttributes) {
           ...objectAttributeValues
         }
       }
@@ -53,16 +56,3 @@ query ticketsByOverview(
     }
   }
 }
-
-fragment objectAttributeValues on ObjectAttributeValue {
-  attribute {
-    name
-    display
-    dataType
-    dataOption
-    screens
-    editable
-    active
-  }
-  value
-}

+ 97 - 4
app/frontend/common/graphql/types.ts

@@ -289,6 +289,8 @@ export type Queries = {
   overviews: OverviewConnection;
   /** The sessionId of the currently authenticated user. */
   sessionId: Scalars['String'];
+  /** Fetch a ticket by ID */
+  ticketById: Ticket;
   /** Fetch tickets of a given ticket overview */
   ticketsByOverview: TicketConnection;
   /** Translations for a given locale */
@@ -317,6 +319,12 @@ export type QueriesOverviewsArgs = {
 };
 
 
+/** All available queries */
+export type QueriesTicketByIdArgs = {
+  ticketId: Scalars['ID'];
+};
+
+
 /** All available queries */
 export type QueriesTicketsByOverviewArgs = {
   after?: InputMaybe<Scalars['String']>;
@@ -356,6 +364,7 @@ export enum TextDirection {
 export type Ticket = Node & ObjectAttributeValueInterface & {
   __typename?: 'Ticket';
   articleCount?: Maybe<Scalars['Int']>;
+  articles: TicketArticleConnection;
   closeAt?: Maybe<Scalars['ISO8601DateTime']>;
   closeDiffInMin?: Maybe<Scalars['Int']>;
   closeEscalationAt?: Maybe<Scalars['ISO8601DateTime']>;
@@ -396,6 +405,64 @@ export type Ticket = Node & ObjectAttributeValueInterface & {
   updatedBy: User;
 };
 
+
+/** Tickets */
+export type TicketArticlesArgs = {
+  after?: InputMaybe<Scalars['String']>;
+  before?: InputMaybe<Scalars['String']>;
+  first?: InputMaybe<Scalars['Int']>;
+  last?: InputMaybe<Scalars['Int']>;
+};
+
+/** Ticket articles */
+export type TicketArticle = Node & {
+  __typename?: 'TicketArticle';
+  body: Scalars['String'];
+  cc?: Maybe<Scalars['String']>;
+  contentType: Scalars['String'];
+  /** Create date/time of the record */
+  createdAt: Scalars['ISO8601DateTime'];
+  /** User that created this record */
+  createdBy: User;
+  from?: Maybe<Scalars['String']>;
+  id: Scalars['ID'];
+  inReplyTo?: Maybe<Scalars['String']>;
+  internal: Scalars['Boolean'];
+  messageId?: Maybe<Scalars['String']>;
+  messageIdMd5?: Maybe<Scalars['String']>;
+  originBy?: Maybe<User>;
+  references?: Maybe<Scalars['String']>;
+  replyTo?: Maybe<Scalars['String']>;
+  subject?: Maybe<Scalars['String']>;
+  to?: Maybe<Scalars['String']>;
+  /** Last update date/time of the record */
+  updatedAt: Scalars['ISO8601DateTime'];
+  /** Last user that updated this record */
+  updatedBy: User;
+};
+
+/** The connection type for TicketArticle. */
+export type TicketArticleConnection = {
+  __typename?: 'TicketArticleConnection';
+  /** A list of edges. */
+  edges?: Maybe<Array<Maybe<TicketArticleEdge>>>;
+  /** A list of nodes. */
+  nodes?: Maybe<Array<Maybe<TicketArticle>>>;
+  /** Information to aid in pagination. */
+  pageInfo: PageInfo;
+  /** Indicates the total number of available records. */
+  totalCount: Scalars['Int'];
+};
+
+/** An edge in a connection. */
+export type TicketArticleEdge = {
+  __typename?: 'TicketArticleEdge';
+  /** A cursor for use in pagination. */
+  cursor: Scalars['String'];
+  /** The item at the end of the edge. */
+  node?: Maybe<TicketArticle>;
+};
+
 /** The connection type for Ticket. */
 export type TicketConnection = {
   __typename?: 'TicketConnection';
@@ -465,7 +532,23 @@ export type TicketState = Node & {
   name: Scalars['String'];
   nextStateId?: Maybe<Scalars['Int']>;
   note?: Maybe<Scalars['String']>;
-  stateTypeName: Scalars['String'];
+  stateType: TicketStateType;
+  /** Last update date/time of the record */
+  updatedAt: Scalars['ISO8601DateTime'];
+  /** Last user that updated this record */
+  updatedBy: User;
+};
+
+/** Ticket state types */
+export type TicketStateType = Node & {
+  __typename?: 'TicketStateType';
+  /** Create date/time of the record */
+  createdAt: Scalars['ISO8601DateTime'];
+  /** User that created this record */
+  createdBy: User;
+  id: Scalars['ID'];
+  name: Scalars['String'];
+  note?: Maybe<Scalars['String']>;
   /** Last update date/time of the record */
   updatedAt: Scalars['ISO8601DateTime'];
   /** Last user that updated this record */
@@ -545,18 +628,28 @@ export type UserEdge = {
   node?: Maybe<User>;
 };
 
+export type ObjectAttributeValuesFragment = { __typename?: 'ObjectAttributeValue', value?: string | null | undefined, attribute: { __typename?: 'ObjectManagerAttribute', name: string, display: string, dataType: string, dataOption?: any | null | undefined, screens?: any | null | undefined, editable: boolean, active: boolean } };
+
+export type TicketsByIdQueryVariables = Exact<{
+  ticketId: Scalars['ID'];
+  withArticles?: InputMaybe<Scalars['Boolean']>;
+  withObjectAttributes?: InputMaybe<Scalars['Boolean']>;
+}>;
+
+
+export type TicketsByIdQuery = { __typename?: 'Queries', ticketById: { __typename?: 'Ticket', id: string, number: string, title: string, createdAt: any, updatedAt: any, owner: { __typename?: 'User', firstname?: string | null | undefined, lastname?: string | null | undefined }, customer: { __typename?: 'User', firstname?: string | null | undefined, lastname?: string | null | undefined }, organization?: { __typename?: 'Organization', name: string } | null | undefined, state: { __typename?: 'TicketState', name: string, stateType: { __typename?: 'TicketStateType', name: string } }, group: { __typename?: 'Group', name: string }, priority: { __typename?: 'TicketPriority', name: string }, articles?: { __typename?: 'TicketArticleConnection', edges?: Array<{ __typename?: 'TicketArticleEdge', node?: { __typename?: 'TicketArticle', subject?: string | null | undefined } | null | undefined } | null | undefined> | null | undefined }, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: string | null | undefined, attribute: { __typename?: 'ObjectManagerAttribute', name: string, display: string, dataType: string, dataOption?: any | null | undefined, screens?: any | null | undefined, editable: boolean, active: boolean } }> } };
+
 export type TicketsByOverviewQueryVariables = Exact<{
   overviewId: Scalars['ID'];
   orderBy?: InputMaybe<TicketOrderBy>;
   orderDirection?: InputMaybe<OrderDirection>;
   cursor?: InputMaybe<Scalars['String']>;
   pageSize?: InputMaybe<Scalars['Int']>;
+  withObjectAttributes?: InputMaybe<Scalars['Boolean']>;
 }>;
 
 
-export type TicketsByOverviewQuery = { __typename?: 'Queries', ticketsByOverview: { __typename?: 'TicketConnection', totalCount: number, edges?: Array<{ __typename?: 'TicketEdge', cursor: string, node?: { __typename?: 'Ticket', id: string, number: string, title: string, createdAt: any, updatedAt: any, owner: { __typename?: 'User', firstname?: string | null | undefined, lastname?: string | null | undefined }, customer: { __typename?: 'User', firstname?: string | null | undefined, lastname?: string | null | undefined }, organization?: { __typename?: 'Organization', name: string } | null | undefined, state: { __typename?: 'TicketState', name: string, stateTypeName: string }, group: { __typename?: 'Group', name: string }, priority: { __typename?: 'TicketPriority', name: string }, objectAttributeValues: Array<{ __typename?: 'ObjectAttributeValue', value?: string | null | undefined, attribute: { __typename?: 'ObjectManagerAttribute', name: string, display: string, dataType: string, dataOption?: any | null | undefined, screens?: any | null | undefined, editable: boolean, active: boolean } }> } | null | undefined } | null | undefined> | null | undefined, pageInfo: { __typename?: 'PageInfo', endCursor?: string | null | undefined, hasNextPage: boolean } } };
-
-export type ObjectAttributeValuesFragment = { __typename?: 'ObjectAttributeValue', value?: string | null | undefined, attribute: { __typename?: 'ObjectManagerAttribute', name: string, display: string, dataType: string, dataOption?: any | null | undefined, screens?: any | null | undefined, editable: boolean, active: boolean } };
+export type TicketsByOverviewQuery = { __typename?: 'Queries', ticketsByOverview: { __typename?: 'TicketConnection', totalCount: number, edges?: Array<{ __typename?: 'TicketEdge', cursor: string, node?: { __typename?: 'Ticket', id: string, number: string, title: string, createdAt: any, updatedAt: any, owner: { __typename?: 'User', firstname?: string | null | undefined, lastname?: string | null | undefined }, customer: { __typename?: 'User', firstname?: string | null | undefined, lastname?: string | null | undefined }, organization?: { __typename?: 'Organization', name: string } | null | undefined, state: { __typename?: 'TicketState', name: string, stateType: { __typename?: 'TicketStateType', name: string } }, group: { __typename?: 'Group', name: string }, priority: { __typename?: 'TicketPriority', name: string }, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: string | null | undefined, attribute: { __typename?: 'ObjectManagerAttribute', name: string, display: string, dataType: string, dataOption?: any | null | undefined, screens?: any | null | undefined, editable: boolean, active: boolean } }> } | null | undefined } | null | undefined> | null | undefined, pageInfo: { __typename?: 'PageInfo', endCursor?: string | null | undefined, hasNextPage: boolean } } };
 
 export type LoginMutationVariables = Exact<{
   login: Scalars['String'];

+ 36 - 2
app/graphql/gql/concern/is_model_object.rb

@@ -18,8 +18,42 @@ module Gql::Concern::IsModelObject
       field :created_by_id, Integer, null: false, description: 'User that created this record'
       field :updated_by_id, Integer, null: false, description: 'Last user that updated this record'
     else
-      field :created_by, Gql::Types::UserType, null: false, description: 'User that created this record'
-      field :updated_by, Gql::Types::UserType, null: false, description: 'Last user that updated this record'
+      belongs_to :created_by, Gql::Types::UserType, null: false, description: 'User that created this record'
+      belongs_to :updated_by, Gql::Types::UserType, null: false, description: 'Last user that updated this record'
+    end
+  end
+
+  class_methods do
+
+    # Using AssociationLoader with has_many and has_and_belongs_to_many didn't work out,
+    #   because the ConnectionTypes generate their own, non-preloadable queries.
+    # See also https://github.com/Shopify/graphql-batch/issues/114.
+
+    def belongs_to(association, *args, **kwargs, &block)
+      kwargs[:resolver_method] = association_resolver(association) do
+        definition = object.class.reflections[association.to_s]
+        id = object.public_send(:"#{definition.plural_name.singularize}_id")
+        Gql::RecordLoader.for(definition.klass).load(id)
+      end
+
+      field(association, *args, **kwargs, &block)
+    end
+
+    def has_one(association, *args, **kwargs, &block) # rubocop:disable Naming/PredicateName
+      kwargs[:resolver_method] = association_resolver(association) do
+        definition = object.class.reflections[association.to_s]
+        Gql::RecordLoader.for(definition.klass, column: definition.foreign_key).load(object.id)
+      end
+
+      field(association, *args, **kwargs, &block)
+    end
+
+    private
+
+    def association_resolver(association, &block)
+      :"resolve_#{association}_association".tap do |resolver_method|
+        define_method(resolver_method, &block)
+      end
     end
   end
 end

+ 21 - 0
app/graphql/gql/queries/ticket_by_id.rb

@@ -0,0 +1,21 @@
+# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+module Gql::Queries
+  class TicketById < BaseQuery
+
+    description 'Fetch a ticket by ID'
+
+    def self.authorize(_obj, ctx)
+      # Pundit authorization will be done via TicketType.
+      ctx.current_user
+    end
+
+    argument :ticket_id, GraphQL::Types::ID, required: true, description: 'Ticket ID'
+
+    type Gql::Types::TicketType, null: false
+
+    def resolve(ticket_id: nil)
+      Gql::ZammadSchema.object_from_id(ticket_id, context) || raise("Cannot find ticket #{ticket_id}")
+    end
+  end
+end

+ 29 - 0
app/graphql/gql/record_loader.rb

@@ -0,0 +1,29 @@
+# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+# https://github.com/Shopify/graphql-batch/blob/af45e5b9e560abb8eb2b97657928e460d4dcf96a/examples/record_loader.rb
+class Gql::RecordLoader < GraphQL::Batch::Loader # rubocop:disable GraphQL/ObjectDescription
+  def initialize(model, column: model.primary_key, where: nil)
+    super()
+    @model = model
+    @column = column.to_s
+    @column_type = model.type_for_attribute(@column)
+    @where = where
+  end
+
+  def load(key)
+    super(@column_type.cast(key))
+  end
+
+  def perform(keys)
+    query(keys).each { |record| fulfill(record.public_send(@column), record) }
+    keys.each { |key| fulfill(key, nil) if !fulfilled?(key) }
+  end
+
+  private
+
+  def query(keys)
+    scope = @model
+    scope = scope.where(@where) if @where
+    scope.where(@column => keys)
+  end
+end

+ 27 - 0
app/graphql/gql/types/ticket/article_type.rb

@@ -0,0 +1,27 @@
+# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+module Gql::Types::Ticket
+  class ArticleType < Gql::Types::BaseObject
+    include Gql::Concern::IsModelObject
+
+    def self.authorize(object, ctx)
+      Pundit.authorize ctx.current_user, object, :show?
+    end
+
+    description 'Ticket articles'
+
+    field :from, String
+    field :to, String
+    field :cc, String
+    field :subject, String
+    field :reply_to, String
+    field :message_id, String
+    field :message_id_md5, String
+    field :in_reply_to, String
+    field :content_type, String, null: false
+    field :references, String
+    field :body, String, null: false
+    field :internal, Boolean, null: false
+    field :origin_by, Gql::Types::UserType, null: true
+  end
+end

Some files were not shown because too many files changed in this diff