amplify-category-api
                                
                                 amplify-category-api copied to clipboard
                                
                                    amplify-category-api copied to clipboard
                            
                            
                            
                        GraphQL generated resolvers are ignoring GSI in 1-to-many relationship list calls, doing table SCAN instead of QUERY
Environment information
System:
  OS: macOS 14.5
  CPU: (10) arm64 Apple M1 Pro
  Memory: 152.22 MB / 32.00 GB
  Shell: /bin/zsh
Binaries:
  Node: 22.2.0 - ~/.nvm/versions/node/v22.2.0/bin/node
  Yarn: undefined - undefined
  npm: 10.7.0 - ~/.nvm/versions/node/v22.2.0/bin/npm
  pnpm: undefined - undefined
NPM Packages:
  @aws-amplify/backend: 1.0.2
  @aws-amplify/backend-cli: 1.0.3
  aws-amplify: 6.3.4
  aws-cdk: 2.143.0
  aws-cdk-lib: 2.143.0
  typescript: 5.4.5
AWS environment variables:
  AWS_PROFILE = gatecode-deploy
  AWS_STS_REGIONAL_ENDPOINTS = regional
  AWS_NODEJS_CONNECTION_REUSE_ENABLED = 1
  AWS_SDK_LOAD_CONFIG = 1
No CDK environment variables
Description
The bug:
I have a model defined in resource.ts:
GateCodes: a
  .model({
    gatePhone: a.string().required(),
    ownerCode: a.string().required(),
    name: a.string().required(),
    // create a reference field to the account (there must be an account to have a GateCodes entry)
    owner: a.string().required(),
    // use that reference field to make a belongsTo relationship
    account: a.belongsTo('Account', 'owner'),
  }).identifier(['gatePhone', 'ownerCode'])
  .authorization(allow => [allow.owner()]),
Account: a
  .model({
    owner: a.string().required().authorization(allow => [allow.owner().to(['read'])]),
    comment: a.string(),
    gateCodes: a.hasMany('GateCodes', 'owner'),
  }).identifier(['owner'])
  .authorization(allow => [allow.ownerDefinedIn('owner')]),
So basically a single owner has one Account, and their account can have many GateCodes.  The GateCodes schema uses the owner field as the reference field, so there's a Global Secondary Index on the owner field.  However, when I fetch the GateCodes after loading an account like this:
this.account = data;
let result = await data?.gateCodes();
console.log("got gate codes- ", result);
You'd think it would do a query on the underlying Dynamo DB table because there's a GSI on the owner which is what it's using to query with.  But it does not, it does a scan instead.  In the CloudWatch logs (after turning on API logging in the AppSync settings) I see the following transformed template:
" \n{"version":"2018-05-29","limit":100,"filter":{"expression":"(((#owner = :and_0_or_0_owner_eq) OR (#owner = :and_0_or_1_owner_eq) OR (#owner = :and_0_or_2_owner_eq)) AND (#owner = :and_1_and_0_owner_eq))","expressionNames":{"#owner":"owner"},"expressionValues":{":and_0_or_1_owner_eq":{"S":"180173d0-4001-70c6-e87f-0e070c83caef"},":and_1_and_0_owner_eq":{"S":"180173d0-4001-70c6-e87f-0e070c83caef"},":and_0_or_0_owner_eq":{"S":"180173d0-4001-70c6-e87f-0e070c83caef::180173d0-4001-70c6-e87f-0e070c83caef"},":and_0_or_2_owner_eq":{"S":"180173d0-4001-70c6-e87f-0e070c83caef"}}},"operation":"Scan"}\n"
The very last entry in there you can see is \"operation\":\"Scan\".  It's the same problem if you try to query the GateCodes directly by passing the owner in (which ought to trigger a query on the GSI as well):
this.client.models.GateCodes.observeQuery({
  filter: {
    owner: {
      eq: this.userService.currentOwner!
    }
  }
}).subscribe({
  next: ({ items, isSynced }) => {
    this.gateCodes = items;
  },
});
This also produces an \"operation\":\"Scan\" in the logs.
The culprit
In the AppSync functions it creates a resolver for this model called QueryListGateCodesDataResolverFn, which has the following relevant code:
#if( !$util.isNull($ctx.stash.modelQueryExpression) && !$util.isNullOrEmpty($ctx.stash.modelQueryExpression.expression) )
  $util.qr($ListRequest.put("operation", "Query"))
  $util.qr($ListRequest.put("query", $ctx.stash.modelQueryExpression))
  #if( !$util.isNull($args.sortDirection) && $args.sortDirection == "DESC" )
    #set( $ListRequest.scanIndexForward = false )
  #else
    #set( $ListRequest.scanIndexForward = true )
  #end
#else
  $util.qr($ListRequest.put("operation", "Scan"))
#end
This code says if there's no modelQueryExpression to do a scan.  There's a related function called QuerylistGateCodespreAuth0Function which builds this variable.  It's too long to put here, but it starts like this:
#set( $modelQueryExpression = {} )
## [Start] Validate key arguments. **
#if( !$util.isNull($ctx.args.ownerCode) && $util.isNull($ctx.args.gatePhone) )
  $util.error("When providing argument 'ownerCode' you must also provide arguments gatePhone", "InvalidArgumentsError")
#end
## [End] Validate key arguments. **
#if( !$util.isNull($ctx.args.gatePhone) )
  #set( $modelQueryExpression.expression = "#gatePhone = :gatePhone" )
  #set( $modelQueryExpression.expressionNames = {
  "#gatePhone": "gatePhone"
} )
  #set( $modelQueryExpression.expressionValues = {
  ":gatePhone": {
      "S": "$ctx.args.gatePhone"
  }
} )
It's looking to see if you set the partition key. After that, it goes only to check if you passed a sort key, and then how you want to sort, etc. What it doesn't do is look to see if you are querying on the GSI that was created automatically when you setup a 1-to-many relationship, and instead do a query on that.
On a large table, this would be very wasteful and expensive to do a scan all the time when a developer thought they had passed sufficient information to do a query.