loading...

AWS Amplify ElasticSearch Query for Interface/Union type with AND/OR operations

tingtingjh profile image Tingting Jiang ・7 min read

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 amplify/backend/api/api_name/schema.graphql might look like:

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")
}

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

/* 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
    }
  }
`;

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 interface or union 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:

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

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.

    Step 1. Modify your schema in AppSync console and click "Save Schema" modify schema Based on your modeling logic, if you chose interface type, you might modify your schema like this:
    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
    }
    
    The union type follows a similar schema change with the absence of implementing the union type. Simply create the union type like this:
    union Object = Collection | Archive
    
    Step 2. Attach resolvers to the custom search Query attach resolver
    Step 3. Choose "ElasticSearchDomain" as data source and write request/response resolvers: data source

    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 Query.searchModels.req.vtl and Query.searchModels.res.vtl, which can be found at amplify/backend/api/your_api_name/build/resolvers.

    When implementing the request resolver, there are certain points worth noting:
  • Update $indexPath to perform multi-document search or wildcard search
  • The Utility helper $util.transform.toElasticSearchQueryDSL defaults by AND 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.
  • Make sure your query accepts external filter inputs, e.g., by using the "must" clause

Here is an example of a request resolver:

#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
        }
      }
    }
  }
}

For a response resolver, most GraphQL query results are extracted from the _source field in ElasticSearch response. However, to be able to identify different model types, __typename meta field is required for interface/union type. Thus, make sure to add __typename into _source for returned interface/union type.

This is an example of a response resolver:

#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
})
    Step 4. Test out custom resolvers in AppSync queries test query
    Step 5. Add resolvers to your API and attach custom resources

    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 amplify/backend/api/api_name; be careful not to touch anything in the amplify/backend/api/api_name/build directory.

  • Update schemal.graphql. Because GraphQL Transform does not support @searchable for interface, you will need to add some extra inputs in order to make the schema compile successfully.
    
    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
    }
    
    
  • Copy and paste the resolvers into resolvers directory with file names: Query.searchObjects.req.vtl and Query.searchObjects.res.vtl.
  • Update amplify/backend/api/api_name/stacks/CustomResouces.json as:
    {//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
    
    
    Step 6. Push API to AWS Cloud

    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."

    With that, your custom search query is ready to use!

Posted on by:

tingtingjh profile

Tingting Jiang

@tingtingjh

Digital library applications development, AWS code development and deployment

Discussion

pic
Editor guide
 

This is incredible! I’ve seen several people asking about this lately, searching for connected types in elasticSearch.

Thanks for sharing!