Skip to content

fix(transform): match AWS DynamoDB filter and condition expressions - #297

Open
bentsku wants to merge 1 commit into
aws-1680-appsync-authtype-from-identityfrom
aws-1703-appsync-transform-dynamodb-expressions
Open

fix(transform): match AWS DynamoDB filter and condition expressions#297
bentsku wants to merge 1 commit into
aws-1680-appsync-authtype-from-identityfrom
aws-1703-appsync-transform-dynamodb-expressions

Conversation

@bentsku

@bentsku bentsku commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

2/2 of a stack, based on #296 — review that first. The two are meant to land under one release.

Problem

toDynamoDBFilterExpression accepted exactly one field carrying exactly one contains and threw Not implemented for <op> on everything else, never calling the general builder next to it. toDynamoDBConditionExpression did call it, and that builder, ported from amplify-appsync-simulator, diverged from AWS:

  • the port dropped Amplify's prefix = null defaults, so undefined reached the value names as :_and_0_id_eq
  • between emitted _1/_2 and no surrounding parentheses
  • the function operators left a space after the comma
  • attributeType fell into the default recursion with a string operand and recursed per character until the stack overflowed
  • size was treated as a field name
  • an unknown operator silently produced an empty expression

What this does

Both entry points now share one recursive builder, replacing the token array with its merge and scopeExpression helpers. AWS produced byte identical output for the two of them across all 66 recorded inputs, so there is nothing to fork.

operator expression
contains notContains beginsWith (contains(#v,:v_contains)), no space after the comma
between (#v BETWEEN :v_between_start AND :v_between_end)
in (#v IN (:v_in_0, :v_in_1)), the one operator that does space its comma
attributeType the type code of a friendly name, string to S, stringSet to SS, and eight more
size not an operator but a wrapper on the target, composing with all of them: (contains(size(#v),:v_size_contains))

A group is wrapped only when it holds more than one member, the filter as a whole never is, and not always is. Members that render to nothing are joined all the same, which is what leaves "(#a = :a_eq) AND " for {a: {eq: 1}, b: {}}.

Two behaviours worth flagging because they contradict the upstream types: the map form of and and or is not supported, although DynamoDBFilterObject describes it, and an input AWS cannot render returns null rather than throwing.

Also fixed: util.dynamodb.toDynamoDB(null) threw a TypeError, reading value.length before handling null, where AWS types it as {"NULL": null}. The transform needs it for a null operand.

Tests

82 snapshots recorded from EvaluateCode in a new __tests__/transform.test.js, covering every operator and operand type, the composition and wrapping rules, the filters that render nothing, the 18 inputs that come back null, the two entry points agreeing, and the shapes the Amplify GraphQL transformer generates.

Snapshots are parsed before comparison because AWS orders the keys of expressionNames and expressionValues inconsistently between runs; two cases keep the raw string to pin the serialisation. The file is new, so recording could not touch the neighbouring snapshots, and the two legacy Transformations ones still pass untouched.

npm test: 345 passed, 3 skipped, 337 snapshots. A replay of the whole probe matrix against the new builder matched AWS on 132 of 132 results.

🤖 Generated with Claude Code

@bentsku

bentsku commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

CI

The workflow's pull_request trigger is filtered to branches: [main], so a stacked PR gets no automatic run and the checks list here stays empty. Dispatched manually against this branch head, which carries both commits:

https://github.com/localstack/appsync-utils/actions/runs/33681334704

unit-test, integration-test and localstack-test all pass. #296 ran the same three on its own head, also passing.

Re-dispatch after any push here with gh workflow run test-appsync-utils.yml --ref aws-1703-appsync-transform-dynamodb-expressions, or widen the trigger if we expect to stack often.

To try both halves of the stack in a LocalStack instance before anything is released, start it with APPSYNC_JS_LIBS_VERSION=aws-1703-appsync-transform-dynamodb-expressions, or the head SHA if the run needs to be reproducible.

`toDynamoDBFilterExpression` accepted exactly one field carrying exactly
one `contains` and threw `Not implemented for <op>` on everything else,
while `toDynamoDBConditionExpression` went through an Amplify derived
builder that diverged from AWS: `undefined` leaked into the value
placeholders as `:_and_0_id_eq`, `between` emitted `_1`/`_2` without
parentheses, the function operators left a space after the comma,
`attributeType` recursed over its operand until the stack overflowed,
`size` was mistaken for a field, and an unknown operator silently
rendered an empty expression.

Both entry points now share one builder. AWS produced byte identical
output for the two of them across every input recorded, along with the
grammar the builder implements: the value placeholder naming, the rule
that a group is wrapped only when it holds more than one member, `size`
as a wrapper around the attribute rather than an operator, the friendly
type names `attributeType` accepts, and `null` in place of an error for
a filter that cannot be rendered.

`util.dynamodb.toDynamoDB` now types `null` as `{"NULL": null}` the way
AWS does, which the transform needs for a null operand.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@bentsku
bentsku force-pushed the aws-1703-appsync-transform-dynamodb-expressions branch from 3f2cf1c to b9bd600 Compare September 2, 2026 20:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant