DEV Community

Tingting Jiang
Tingting Jiang

Posted on

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

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!

Top comments (1)

Collapse
 
andthensumm profile image
Matt Marks 🐣

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

Thanks for sharing!