AWS Amplify ElasticSearch Query for Interface/Union type with AND/OR operations
Tags: amplify, graphql, elasticsearch, interface
Published: 2020-05-23 18:30:38

AWS Amplify GraphQL Transform can automatically create the backend API according to your data model schema. With built-in Directives, such as @model, @connection, @key, and @searchable, Amplify further facilitates the connection between your data models with other Amazon Web services including DynamoDB and ElasticSearch.

For example, with the data models, Collection and Archive, our initial <em>amplify/backend/api/api\_name/schema.graphql</em> might look like:
<pre class="highlight plaintext">
<code>type Collection
  @model
  @searchable
  @key(
    fields: ["identifier"]
    name: "Identifier"
    queryField: "collectionByIdentifier"
  ) {
  id: ID!
  title: String!
  identifier: String!
  description: String
  date: String
  collection_category: String!
  visibility: Boolean!
  parent_collection: [String!]
  archives: [Archive] @connection(name: "CollectionArchives")
}
type Archive
  @model
  @searchable
  @key(
    fields: ["identifier"]
    name: "Identifier"
    queryField: "archiveByIdentifier"
  ) {
  id: ID!
  title: String!
  identifier: String!
  description: String
  date: String
  parent_collection: [String!]
  item_category: String!
  visibility: Boolean!
  collection: Collection @connection(name: "CollectionArchives")
}</code>
</pre>

By running “amplify push”, a full CloudFormation template will be set up for you with your new API. In the <em>graphql/queries.js</em>, you can see the automatically generated queries. In particular, @searchable directive streams your data into the ElasticSearch engine with <em>searchArchives</em> and <em>searchCollections</em> queries that are ready at your disposal.

<pre class="highlight plaintext">
<code>/* eslint-disable */
// this is an auto generated file. This will be overwritten

export const getCollection = /* GraphQL */ `
  query GetCollection($id: ID!) {
    getCollection(id: $id) {
      id
      title
      identifier
      description
      creator
      source
      circa
      date
      collection_category
      visibility
      parent_collection
      archives {
        items {
          id
          title
          identifier
          description
          date
          parent_collection
          item_category
          visibility
        }
        nextToken
      }
    }
  }
`;
export const listCollections = /* GraphQL */ `
  query ListCollections(
    $filter: ModelCollectionFilterInput
    $limit: Int
    $nextToken: String
  ) {
    listCollections(filter: $filter, limit: $limit, nextToken: $nextToken) {
      items {
        id
        title
        identifier
        description
        date
        collection_category
        visibility
        parent_collection
        archives {
          nextToken
        }
      }
      nextToken
    }
  }
`;
export const getArchive = /* GraphQL */ `
  query GetArchive($id: ID!) {
    getArchive(id: $id) {
      id
      title
      identifier
      description
      date
      parent_collection
      item_category
      visibility
      collection {
        id
        title
        identifier
        description
        date
        collection_category
        visibility
        parent_collection
        archives {
          nextToken
        }
      }
    }
  }
`;
export const listArchives = /* GraphQL */ `
  query ListArchives(
    $filter: ModelArchiveFilterInput
    $limit: Int
    $nextToken: String
  ) {
    listArchives(filter: $filter, limit: $limit, nextToken: $nextToken) {
      items {
        id
        title
        identifier
        description
        date
        parent_collection
        item_category
        visibility
        collection {
          id
          title
          identifier
          description
          date
          collection_category
          visibility
          parent_collection
        }
      }
      nextToken
    }
  }
`;
export const collectionByIdentifier = /* GraphQL */ `
  query CollectionByIdentifier(
    $identifier: String
    $sortDirection: ModelSortDirection
    $filter: ModelCollectionFilterInput
    $limit: Int
    $nextToken: String
  ) {
    collectionByIdentifier(
      identifier: $identifier
      sortDirection: $sortDirection
      filter: $filter
      limit: $limit
      nextToken: $nextToken
    ) {
      items {
        id
        title
        identifier
        description
        date
        collection_category
        visibility
        parent_collection
        archives {
          nextToken
        }
      }
      nextToken
    }
  }
`;
export const archiveByIdentifier = /* GraphQL */ `
  query ArchiveByIdentifier(
    $identifier: String
    $sortDirection: ModelSortDirection
    $filter: ModelArchiveFilterInput
    $limit: Int
    $nextToken: String
  ) {
    archiveByIdentifier(
      identifier: $identifier
      sortDirection: $sortDirection
      filter: $filter
      limit: $limit
      nextToken: $nextToken
    ) {
      items {
        id
        title
        identifier
        description
        date
        parent_collection
        item_category
        visibility
        collection {
          id
          title
          identifier
          description
          date
          collection_category
          visibility
          parent_collection
        }
      }
      nextToken
    }
  }
`;
export const searchCollections = /* GraphQL */ `
  query SearchCollections(
    $filter: SearchableCollectionFilterInput
    $sort: SearchableCollectionSortInput
    $limit: Int
    $nextToken: String
  ) {
    searchCollections(
      filter: $filter
      sort: $sort
      limit: $limit
      nextToken: $nextToken
    ) {
      items {
        id
        title
        identifier
        description
        date
        collection_category
        visibility
        parent_collection
        archives {
          nextToken
        }
      }
      nextToken
      total
    }
  }
`;
export const searchArchives = /* GraphQL */ `
  query SearchArchives(
    $filter: SearchableArchiveFilterInput
    $sort: SearchableArchiveSortInput
    $limit: Int
    $nextToken: String
  ) {
    searchArchives(
      filter: $filter
      sort: $sort
      limit: $limit
      nextToken: $nextToken
    ) {
      items {
        id
        title
        identifier
        description
        date
        parent_collection
        item_category
        visibility
        collection {
          id
          title
          identifier
          description
          date
          collection_category
          visibility
          parent_collection
        }
      }
      nextToken
      total
    }
  }
`;
</code>
</pre>

Now what if the application requires to search for both Collection and Archive records with sorting capability by some common field such as “title”? This can be achieved by using GraphQL’s <em>interface</em> or <em>union</em> type as it represents the generalization of multiple types. Unfortunately, Amplify GraphQL Transform does not have @searchable directive support for either of them. This comes with our problem to solve:

<h2>Create custom resolvers for Interface/Union type to search across multiple indices</h2>

To provide this customized search query, we need to add corresponding types into the schema and then attach request/response resolvers to the query. It is much more intuitive to iterate the process of modifying the schema, implementing the resolvers, and testing the query by working directly in the AWS AppSync console.
<ul>
<ol>Step 1. Modify your schema in AppSync console and click "Save Schema"
<img src="https://dev-to-uploads.s3.amazonaws.com/i/reknvfb4kqt13fz8uu85.png" alt="modify schema" loading="lazy">

Based on your modeling logic, if you chose <em>interface</em> type, you might modify your schema like this:
<pre class="highlight plaintext">
<code>interface Object {
  id: ID!
  title: String!
  identifier: String!
  description: String
  date: String
  visibility: Boolean!
  parent_collection: [String!]
}

type Collection implements Object
  # the rest is unchanged
  
type Archive implements Object
  # the rest is unchanged
  
type Query {
  searchObjects(
    sort: SearchableCollectionSortInput
    filter: SearchableCollectionFilterInput
    limit: Int
    nextToken: String
    category: String
  ): SearchableObjectConnection
  # the rest is unchanged
}

type SearchableObjectConnection {
  items: [Object]
  nextToken: String
  total: Int
}</code>
</pre> 
 
The <em>union</em> type follows a similar schema change with the absence of implementing the <em>union</em> type. Simply create the <em>union</em> type like this:

<pre class="highlight plaintext">
<code>union Object = Collection | Archive</code>
</pre></ol>
<ol>Step 2. Attach resolvers to the custom search Query
<img src="https://dev-to-uploads.s3.amazonaws.com/i/rtqxh6nljqv8ghrf30dy.png" alt="attach resolver" loading="lazy"></ol>
<ol>Step 3. Choose "ElasticSearchDomain" as data source and write request/response resolvers:
<img src="https://dev-to-uploads.s3.amazonaws.com/i/yfuuu7rtcdg3owno43p0.png" alt="data source" loading="lazy">

<p>To provide a starting point, GraphQL Transform auto-generated resolvers (via @searchable in the original schema) can be served as a template for your custom resolvers. They are <em>Query.searchModels.req.vtl</em> and <em>Query.searchModels.res.vtl</em>, which can be found at
<em>amplify/backend/api/your_api_name/build/resolvers</em>.</p>

When implementing the request resolver, there are certain points worth noting:
<ul>
<li>
Update <em>$indexPath</em> to perform multi-document search or wildcard search</li>
<li>The Utility helper <em>$util.transform.toElasticSearchQueryDSL</em> defaults by <em>AND</em> operation. If your Query logic involves other complex operations, write your own ElasticSearch Query DSL. For example, the OR operation can be achieved by using the “should” clause and “minimum_should_match” parameter.</li>
<li>Make sure your query accepts external filter inputs, e.g., by using the "must" clause</li>
</ul>

Here is an example of a request resolver:
<pre class="highlight plaintext">
<code>#set( $indexPath = "/*/doc/_search" )
#set( $nonKeywordFields = ["visibility"] )
#if( $util.isNullOrEmpty($context.args.sort) )
  #set( $sortDirection = "desc" )
  #set( $sortField = "id" )
#else
  #set( $sortDirection = $util.defaultIfNull($context.args.sort.direction, "desc") )
  #set( $sortField = $util.defaultIfNull($context.args.sort.field, "id") )
#end
#if( $nonKeywordFields.contains($sortField) )
  #set( $sortField0 = $util.toJson($sortField) )
#else
  #set( $sortField0 = $util.toJson("${sortField}.keyword") )
#end
{
  "version": "2018-05-29",
  "operation":"GET",
  "path":"$indexPath",
  "params":{
    "body":{
      #if( $context.args.nextToken ) "search_after": [$util.toJson($context.args.nextToken)], #end
      "size": #if( $context.args.limit ) $context.args.limit #else 10 #end,
      "sort": [{$sortField0: { "order" : $util.toJson($sortDirection) }}],
      "query": {
        "bool": {
          #if( $context.args.filter ) "must": $util.transform.toElasticsearchQueryDSL($ctx.args.filter), #end
          "filter": {
            "term": {
              "visibility": "true"
            }
          },
          "should": [
            {
              "bool": {
                "must": {
                  "match": {
                    "collection_category": "$context.arguments.category"
                  }
                },
                "must_not": {
                  "exists": {
                    "field": "parent_collection"
                  }
                }
              }
            },
            {
              "bool": {
                "must": {
                  "match": {
                    "item_category": "$context.arguments.category"
                  }
                }
              }
            }
          ],
          "minimum_should_match": 1
        }
      }
    }
  }
}</code>
</pre>
For a response resolver, most GraphQL query results are extracted from the <em>\_source</em> field in ElasticSearch response. However, to be able to identify different model types, <em>\_\_typename</em> meta field is required for <em>interface/union</em> type. Thus, make sure to add <em>\_\_typename</em> into <em>\_source</em> for returned <em>interface/union</em> type.

This is an example of a response resolver:
<pre class="highlight plaintext">
<code>#set( $es_items = [] )
#foreach( $entry in $context.result.hits.hits )
  #if( !$foreach.hasNext )
    #set( $nextToken = $entry.sort.get(0) )
  #end
  #foreach ( $mapEntry in $entry.entrySet() )
    #if( $mapEntry.key == "_source" )
      #if( $mapEntry.value.get("collection_category") )
        $util.qr( $mapEntry.value.put("__typename", "Collection") )
      #else
        $util.qr( $mapEntry.value.put("__typename", "Archive") )
      #end
    #end
  #end
  $util.qr( $es_items.add($entry.get("_source")) )
#end

$util.toJson({
  "items": $es_items,
  "total": $ctx.result.hits.total,
  "nextToken": $nextToken
})</code>
</pre>
 
</ol>

<ol>Step 4. Test out custom resolvers in AppSync queries
<img src="https://dev-to-uploads.s3.amazonaws.com/i/kwbg3p64lw8o19h1j0zz.png" alt="test query" loading="lazy">
</ol>

<ol>Step 5. Add resolvers to your API and attach custom resources

<p>After the search query in AppSync works as expected, we need to update the API in Amplify. Running "amplify pull" will not update the API for you. Everything needs to be changed in <em>amplify/backend/api/api_name</em>; be careful not to touch anything in the <em>amplify/backend/api/api_name/build</em> directory.</p>
<ul>
<li>Update <em>schemal.graphql</em>. Because GraphQL Transform does not support @searchable for <em>interface</em>, you will need to add some extra <em>input</em>s in order to make the schema compile successfully.
<pre class="highlight plaintext">
<code>
interface Object {
  id: ID!
  title: String!
  identifier: String!
  description: String
  date: String
  visibility: Boolean!
  parent_collection: [String!]
}

type Collection implements Object
  @model
  @searchable
  @key(
    fields: ["identifier"]
    name: "Identifier"
    queryField: "collectionByIdentifier"
  ) {
  id: ID!
  title: String!
  identifier: String!
  description: String
  date: String
  collection_category: String!
  visibility: Boolean!
  parent_collection: [String!]
  archives: [Archive] @connection(name: "CollectionArchives")
}
type Archive implements Object
  @model
  @searchable
  @key(
    fields: ["identifier"]
    name: "Identifier"
    queryField: "archiveByIdentifier"
  ) {
  id: ID!
  title: String!
  identifier: String!
  description: String
  date: String
  parent_collection: [String!]
  item_category: String!
  visibility: Boolean!
  collection: Collection @connection(name: "CollectionArchives")
}
type Query {
  searchObjects(
    sort: SearchableObjectSortInput
    filter: SearchableObjectFilterInput
    limit: Int
    nextToken: String
    category: String
  ): SearchableObjectConnection
}

type SearchableObjectConnection {
  items: [Object]
  nextToken: String
  total: Int
}

input SearchableObjectFilterInput {
  id: SearchableIDFilterInput
  title: SearchableStringFilterInput
  identifier: SearchableStringFilterInput
  description: SearchableStringFilterInput
  date: SearchableStringFilterInput
  visibility: SearchableBooleanFilterInput
  parent_collection: SearchableStringFilterInput
  and: [SearchableObjectFilterInput]
  or: [SearchableObjectFilterInput]
  not: SearchableObjectFilterInput
}

input SearchableBooleanFilterInput {
  eq: Boolean
  ne: Boolean
}

input SearchableObjectSortInput {
  field: SearchableObjectSortableFields
  direction: SearchableSortDirection
}

enum SearchableObjectSortableFields {
  id
  title
  identifier
  description
  date
}

input SearchableIDFilterInput {
  ne: ID
  gt: ID
  lt: ID
  gte: ID
  lte: ID
  eq: ID
  match: ID
  matchPhrase: ID
  matchPhrasePrefix: ID
  multiMatch: ID
  exists: Boolean
  wildcard: ID
  regexp: ID
}

enum SearchableSortDirection {
  asc
  desc
}

input SearchableStringFilterInput {
  ne: String
  gt: String
  lt: String
  gte: String
  lte: String
  eq: String
  match: String
  matchPhrase: String
  matchPhrasePrefix: String
  multiMatch: String
  exists: Boolean
  wildcard: String
  regexp: String
}
</code>
</pre>
</li>
<li>Copy and paste the resolvers into resolvers directory with file names: <em>Query.searchObjects.req.vtl</em> and <em>Query.searchObjects.res.vtl</em>. </li>
<li>Update <em>amplify/backend/api/api_name/stacks/CustomResouces.json</em> as:
<pre class="highlight plaintext">
<code>{//Anything before "Resouces" remains the same
  "Resources": {
    "QuerySearchObjectResolver": {
      "Type": "AWS::AppSync::Resolver",
      "Properties": {
        "ApiId": {
          "Ref": "AppSyncApiId"
        },
        "DataSourceName": "ElasticSearchDomain",
        "TypeName": "Query",
        "FieldName": "searchObjects",
        "RequestMappingTemplateS3Location": {
          "Fn::Sub": [
            "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/Query.searchObjects.req.vtl",
            {
              "S3DeploymentBucket": {
                "Ref": "S3DeploymentBucket"
              },
              "S3DeploymentRootKey": {
                "Ref": "S3DeploymentRootKey"
              }
            }
          ]
        },
        "ResponseMappingTemplateS3Location": {
          "Fn::Sub": [
            "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/Query.searchObjects.res.vtl",
            {
              "S3DeploymentBucket": {
                "Ref": "S3DeploymentBucket"
              },
              "S3DeploymentRootKey": {
                "Ref": "S3DeploymentRootKey"
              }
            }
          ]
        }
      }
    }
  },
//Anything after "Resources" remains the same
</code>
</pre>
</li>
</ul>
</ol>
<ol>Step 6. Push API to AWS Cloud
<p>To avoid the "Only one resolver is allowed per field" error, make sure to delete the resolvers attached to the custom search query (in AWS AppSync console) before running "amplify push."</p>

With that, your custom search query is ready to use!
</ul>