From b9bd6001b5cb8db8910cc7e76b5488c04d6b742a Mon Sep 17 00:00:00 2001 From: Benjamin Simon Date: Wed, 2 Sep 2026 19:09:12 +0200 Subject: [PATCH] fix(transform): match AWS DynamoDB filter and condition expressions `toDynamoDBFilterExpression` accepted exactly one field carrying exactly one `contains` and threw `Not implemented for ` 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 --- README.md | 2 +- __tests__/__snapshots__/index.test.js.snap | 6 + .../__snapshots__/transform.test.js.snap | 992 ++++++++++++++++++ __tests__/index.test.js | 3 + __tests__/transform.test.js | 216 ++++ index.js | 362 +++---- 6 files changed, 1391 insertions(+), 190 deletions(-) create mode 100644 __tests__/__snapshots__/transform.test.js.snap create mode 100644 __tests__/transform.test.js diff --git a/README.md b/README.md index 77bf55c..38c9d94 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ This package provides an implementation for the `@aws-appsync/utils` package tha ## Changelog: -- v0.1.6: add `util.authType()`, derived from the identity of the request; a host installs the request through the new `setResolverContext` export +- v0.1.6: add `util.authType()`, derived from the identity of the request; a host installs the request through the new `setResolverContext` export. `util.transform.toDynamoDBFilterExpression` and `toDynamoDBConditionExpression` now cover every operator, including `between`, `in`, `size` and `attributeType`, and nested `and`/`or`/`not`; `util.dynamodb.toDynamoDB` accepts `null` - v0.1.5: add `rds` `beginsWith`, `between` and `size` conditions, table aliases and `sql` template `where` clauses; invalid input now raises AWS's validation error instead of a raw `TypeError`, and the wildcard conditions require a string - v0.1.4: fix `rds` query builders with empty `orderBy`, `where` and `values` inputs, `contains` wildcards and multiple conditions per column; `orderBy` `dir` is now restricted to `ASC`/`DESC` - v0.1.3: fix `rds` query builders with star columns, empty `where` objects and nullable `limit`/`offset` diff --git a/__tests__/__snapshots__/index.test.js.snap b/__tests__/__snapshots__/index.test.js.snap index 9234ca8..2b77f41 100644 --- a/__tests__/__snapshots__/index.test.js.snap +++ b/__tests__/__snapshots__/index.test.js.snap @@ -304,6 +304,12 @@ exports[`dynamodb helpers toDynamoDB boolean 1`] = ` } `; +exports[`dynamodb helpers toDynamoDB null 1`] = ` +{ + "NULL": null, +} +`; + exports[`dynamodb helpers toDynamoDB number 1`] = ` { "N": 12345, diff --git a/__tests__/__snapshots__/transform.test.js.snap b/__tests__/__snapshots__/transform.test.js.snap new file mode 100644 index 0000000..3eaf207 --- /dev/null +++ b/__tests__/__snapshots__/transform.test.js.snap @@ -0,0 +1,992 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`DynamoDB expression transforms Amplify model operations a list filter combining a prefix and a range 1`] = ` +{ + "expression": "((begins_with(#content,:or_0_content_beginsWith)) OR (#id BETWEEN :or_1_id_between_start AND :or_1_id_between_end))", + "expressionNames": { + "#content": "content", + "#id": "id", + }, + "expressionValues": { + ":or_0_content_beginsWith": { + "S": "a", + }, + ":or_1_id_between_end": { + "S": "9", + }, + ":or_1_id_between_start": { + "S": "1", + }, + }, +} +`; + +exports[`DynamoDB expression transforms Amplify model operations a list filter combining content and a flag 1`] = ` +{ + "expression": "((contains(#content,:and_0_content_contains)) AND (#done = :and_1_done_eq))", + "expressionNames": { + "#content": "content", + "#done": "done", + }, + "expressionValues": { + ":and_0_content_contains": { + "S": "a", + }, + ":and_1_done_eq": { + "BOOL": true, + }, + }, +} +`; + +exports[`DynamoDB expression transforms Amplify model operations a list filter matching content 1`] = ` +{ + "expression": "(contains(#content,:content_contains))", + "expressionNames": { + "#content": "content", + }, + "expressionValues": { + ":content_contains": { + "S": "foo", + }, + }, +} +`; + +exports[`DynamoDB expression transforms Amplify model operations the condition guarding a create 1`] = ` +{ + "expression": "(attribute_not_exists(#id))", + "expressionNames": { + "#id": "id", + }, + "expressionValues": {}, +} +`; + +exports[`DynamoDB expression transforms Amplify model operations the condition guarding an update 1`] = ` +{ + "expression": "((attribute_exists(#id)) AND (#_version = :and_1__version_eq))", + "expressionNames": { + "#_version": "_version", + "#id": "id", + }, + "expressionValues": { + ":and_1__version_eq": { + "N": 1, + }, + }, +} +`; + +exports[`DynamoDB expression transforms attributeExists false asks whether it does not 1`] = ` +{ + "expression": "(attribute_not_exists(#v))", + "expressionNames": { + "#v": "v", + }, + "expressionValues": {}, +} +`; + +exports[`DynamoDB expression transforms attributeExists true asks whether the attribute exists 1`] = ` +{ + "expression": "(attribute_exists(#v))", + "expressionNames": { + "#v": "v", + }, + "expressionValues": {}, +} +`; + +exports[`DynamoDB expression transforms attributeType every friendly type name becomes its DynamoDB type code 1`] = ` +{ + "expression": "(attribute_type(#a,:a_attributeType)) AND (attribute_type(#b,:b_attributeType)) AND (attribute_type(#c,:c_attributeType)) AND (attribute_type(#d,:d_attributeType)) AND (attribute_type(#e,:e_attributeType)) AND (attribute_type(#f,:f_attributeType)) AND (attribute_type(#g,:g_attributeType)) AND (attribute_type(#h,:h_attributeType)) AND (attribute_type(#i,:i_attributeType)) AND (attribute_type(#j,:j_attributeType))", + "expressionNames": { + "#a": "a", + "#b": "b", + "#c": "c", + "#d": "d", + "#e": "e", + "#f": "f", + "#g": "g", + "#h": "h", + "#i": "i", + "#j": "j", + }, + "expressionValues": { + ":a_attributeType": { + "S": "NULL", + }, + ":b_attributeType": { + "S": "S", + }, + ":c_attributeType": { + "S": "SS", + }, + ":d_attributeType": { + "S": "N", + }, + ":e_attributeType": { + "S": "NS", + }, + ":f_attributeType": { + "S": "B", + }, + ":g_attributeType": { + "S": "BS", + }, + ":h_attributeType": { + "S": "BOOL", + }, + ":i_attributeType": { + "S": "L", + }, + ":j_attributeType": { + "S": "M", + }, + }, +} +`; + +exports[`DynamoDB expression transforms between ignores anything past the second bound 1`] = ` +{ + "expression": "(#v BETWEEN :v_between_start AND :v_between_end)", + "expressionNames": { + "#v": "v", + }, + "expressionValues": { + ":v_between_end": { + "S": "9", + }, + ":v_between_start": { + "S": "1", + }, + }, +} +`; + +exports[`DynamoDB expression transforms between names its bounds start and end 1`] = ` +{ + "expression": "(#v BETWEEN :v_between_start AND :v_between_end)", + "expressionNames": { + "#v": "v", + }, + "expressionValues": { + ":v_between_end": { + "S": "9", + }, + ":v_between_start": { + "S": "1", + }, + }, +} +`; + +exports[`DynamoDB expression transforms between numeric bounds 1`] = ` +{ + "expression": "(#v BETWEEN :v_between_start AND :v_between_end)", + "expressionNames": { + "#v": "v", + }, + "expressionValues": { + ":v_between_end": { + "N": 9, + }, + ":v_between_start": { + "N": 1, + }, + }, +} +`; + +exports[`DynamoDB expression transforms both entry points render a filter the same way a comparison as a condition 1`] = ` +{ + "expression": "(#content = :content_eq)", + "expressionNames": { + "#content": "content", + }, + "expressionValues": { + ":content_eq": { + "S": "x", + }, + }, +} +`; + +exports[`DynamoDB expression transforms both entry points render a filter the same way a comparison as a filter 1`] = ` +{ + "expression": "(#content = :content_eq)", + "expressionNames": { + "#content": "content", + }, + "expressionValues": { + ":content_eq": { + "S": "x", + }, + }, +} +`; + +exports[`DynamoDB expression transforms both entry points render a filter the same way a group of two members as a condition 1`] = ` +{ + "expression": "((#content = :and_0_content_eq) AND (attribute_exists(#version)))", + "expressionNames": { + "#content": "content", + "#version": "version", + }, + "expressionValues": { + ":and_0_content_eq": { + "S": "x", + }, + }, +} +`; + +exports[`DynamoDB expression transforms both entry points render a filter the same way a group of two members as a filter 1`] = ` +{ + "expression": "((#content = :and_0_content_eq) AND (attribute_exists(#version)))", + "expressionNames": { + "#content": "content", + "#version": "version", + }, + "expressionValues": { + ":and_0_content_eq": { + "S": "x", + }, + }, +} +`; + +exports[`DynamoDB expression transforms both entry points render a filter the same way a negated range as a condition 1`] = ` +{ + "expression": "(NOT (#id BETWEEN :not_id_between_start AND :not_id_between_end))", + "expressionNames": { + "#id": "id", + }, + "expressionValues": { + ":not_id_between_end": { + "S": "9", + }, + ":not_id_between_start": { + "S": "1", + }, + }, +} +`; + +exports[`DynamoDB expression transforms both entry points render a filter the same way a negated range as a filter 1`] = ` +{ + "expression": "(NOT (#id BETWEEN :not_id_between_start AND :not_id_between_end))", + "expressionNames": { + "#id": "id", + }, + "expressionValues": { + ":not_id_between_end": { + "S": "9", + }, + ":not_id_between_start": { + "S": "1", + }, + }, +} +`; + +exports[`DynamoDB expression transforms comparison operators eq 1`] = ` +{ + "expression": "(#v = :v_eq)", + "expressionNames": { + "#v": "v", + }, + "expressionValues": { + ":v_eq": { + "S": "x", + }, + }, +} +`; + +exports[`DynamoDB expression transforms comparison operators ge 1`] = ` +{ + "expression": "(#v >= :v_ge)", + "expressionNames": { + "#v": "v", + }, + "expressionValues": { + ":v_ge": { + "S": "x", + }, + }, +} +`; + +exports[`DynamoDB expression transforms comparison operators gt 1`] = ` +{ + "expression": "(#v > :v_gt)", + "expressionNames": { + "#v": "v", + }, + "expressionValues": { + ":v_gt": { + "S": "x", + }, + }, +} +`; + +exports[`DynamoDB expression transforms comparison operators le 1`] = ` +{ + "expression": "(#v <= :v_le)", + "expressionNames": { + "#v": "v", + }, + "expressionValues": { + ":v_le": { + "S": "x", + }, + }, +} +`; + +exports[`DynamoDB expression transforms comparison operators lt 1`] = ` +{ + "expression": "(#v < :v_lt)", + "expressionNames": { + "#v": "v", + }, + "expressionValues": { + ":v_lt": { + "S": "x", + }, + }, +} +`; + +exports[`DynamoDB expression transforms comparison operators ne 1`] = ` +{ + "expression": "(#v <> :v_ne)", + "expressionNames": { + "#v": "v", + }, + "expressionValues": { + ":v_ne": { + "S": "x", + }, + }, +} +`; + +exports[`DynamoDB expression transforms filters that cannot be rendered a field holding a value instead of operators 1`] = `null`; + +exports[`DynamoDB expression transforms filters that cannot be rendered a field nested in a field 1`] = `null`; + +exports[`DynamoDB expression transforms filters that cannot be rendered a group holding a null member 1`] = `null`; + +exports[`DynamoDB expression transforms filters that cannot be rendered a list of values that is not a list 1`] = `null`; + +exports[`DynamoDB expression transforms filters that cannot be rendered a not given a list 1`] = `null`; + +exports[`DynamoDB expression transforms filters that cannot be rendered a range that is not a list 1`] = `null`; + +exports[`DynamoDB expression transforms filters that cannot be rendered a range with a single bound 1`] = `null`; + +exports[`DynamoDB expression transforms filters that cannot be rendered a string 1`] = `null`; + +exports[`DynamoDB expression transforms filters that cannot be rendered an and given a map instead of a list 1`] = `null`; + +exports[`DynamoDB expression transforms filters that cannot be rendered an and given null 1`] = `null`; + +exports[`DynamoDB expression transforms filters that cannot be rendered an array 1`] = `null`; + +exports[`DynamoDB expression transforms filters that cannot be rendered an unknown operator 1`] = `null`; + +exports[`DynamoDB expression transforms filters that cannot be rendered attributeExists given something other than a boolean 1`] = `null`; + +exports[`DynamoDB expression transforms filters that cannot be rendered attributeType given a type code instead of a type name 1`] = `null`; + +exports[`DynamoDB expression transforms filters that cannot be rendered attributeType given an unknown name 1`] = `null`; + +exports[`DynamoDB expression transforms filters that cannot be rendered no filter at all 1`] = `null`; + +exports[`DynamoDB expression transforms filters that cannot be rendered null 1`] = `null`; + +exports[`DynamoDB expression transforms filters that cannot be rendered size given a value instead of operators 1`] = `null`; + +exports[`DynamoDB expression transforms filters that render no expression a field without operators keeps its name 1`] = ` +{ + "expression": "", + "expressionNames": { + "#v": "v", + }, + "expressionValues": {}, +} +`; + +exports[`DynamoDB expression transforms filters that render no expression a size without operators keeps the name of its field 1`] = ` +{ + "expression": "", + "expressionNames": { + "#v": "v", + }, + "expressionValues": {}, +} +`; + +exports[`DynamoDB expression transforms filters that render no expression an empty field beside a comparison leaves a dangling joiner 1`] = ` +{ + "expression": "(#a = :a_eq) AND ", + "expressionNames": { + "#a": "a", + "#b": "b", + }, + "expressionValues": { + ":a_eq": { + "N": 1, + }, + }, +} +`; + +exports[`DynamoDB expression transforms filters that render no expression an empty filter 1`] = ` +{ + "expression": "", + "expressionNames": {}, + "expressionValues": {}, +} +`; + +exports[`DynamoDB expression transforms filters that render no expression an empty group 1`] = ` +{ + "expression": "", + "expressionNames": {}, + "expressionValues": {}, +} +`; + +exports[`DynamoDB expression transforms filters that render no expression an empty list of values 1`] = ` +{ + "expression": "(null)", + "expressionNames": { + "#v": "v", + }, + "expressionValues": {}, +} +`; + +exports[`DynamoDB expression transforms filters that render no expression an empty member of a group leaves a dangling joiner 1`] = ` +{ + "expression": "((#a = :and_0_a_eq) AND )", + "expressionNames": { + "#a": "a", + }, + "expressionValues": { + ":and_0_a_eq": { + "N": 1, + }, + }, +} +`; + +exports[`DynamoDB expression transforms filters that render no expression not of an empty filter 1`] = ` +{ + "expression": "(NOT )", + "expressionNames": {}, + "expressionValues": {}, +} +`; + +exports[`DynamoDB expression transforms function operators beginsWith 1`] = ` +{ + "expression": "(begins_with(#v,:v_beginsWith))", + "expressionNames": { + "#v": "v", + }, + "expressionValues": { + ":v_beginsWith": { + "S": "x", + }, + }, +} +`; + +exports[`DynamoDB expression transforms function operators contains leaves no space after the comma 1`] = ` +{ + "expression": "(contains(#v,:v_contains))", + "expressionNames": { + "#v": "v", + }, + "expressionValues": { + ":v_contains": { + "S": "x", + }, + }, +} +`; + +exports[`DynamoDB expression transforms function operators notContains 1`] = ` +{ + "expression": "(NOT contains(#v,:v_notContains))", + "expressionNames": { + "#v": "v", + }, + "expressionValues": { + ":v_notContains": { + "S": "x", + }, + }, +} +`; + +exports[`DynamoDB expression transforms grouping a field beside a group 1`] = ` +{ + "expression": "(#a = :a_eq) AND (#b = :and_0_b_eq)", + "expressionNames": { + "#a": "a", + "#b": "b", + }, + "expressionValues": { + ":a_eq": { + "N": 1, + }, + ":and_0_b_eq": { + "N": 2, + }, + }, +} +`; + +exports[`DynamoDB expression transforms grouping a field carrying two operators is wrapped 1`] = ` +{ + "expression": "((#v >= :v_ge) AND (#v <= :v_le))", + "expressionNames": { + "#v": "v", + }, + "expressionValues": { + ":v_ge": { + "N": 1, + }, + ":v_le": { + "N": 5, + }, + }, +} +`; + +exports[`DynamoDB expression transforms grouping a field name is used verbatim, dots included 1`] = ` +{ + "expression": "(#a.b = :a.b_eq)", + "expressionNames": { + "#a.b": "a.b", + }, + "expressionValues": { + ":a.b_eq": { + "N": 1, + }, + }, +} +`; + +exports[`DynamoDB expression transforms grouping an and inside an or 1`] = ` +{ + "expression": "(((#a = :or_0_and_0_a_eq) AND (#b = :or_0_and_1_b_eq)) OR (#c = :or_1_c_eq))", + "expressionNames": { + "#a": "a", + "#b": "b", + "#c": "c", + }, + "expressionValues": { + ":or_0_and_0_a_eq": { + "N": 1, + }, + ":or_0_and_1_b_eq": { + "N": 2, + }, + ":or_1_c_eq": { + "N": 3, + }, + }, +} +`; + +exports[`DynamoDB expression transforms grouping an and of one member is not wrapped 1`] = ` +{ + "expression": "(#a = :and_0_a_eq)", + "expressionNames": { + "#a": "a", + }, + "expressionValues": { + ":and_0_a_eq": { + "N": 1, + }, + }, +} +`; + +exports[`DynamoDB expression transforms grouping an and of two members is wrapped 1`] = ` +{ + "expression": "((#a = :and_0_a_eq) AND (#b = :and_1_b_eq))", + "expressionNames": { + "#a": "a", + "#b": "b", + }, + "expressionValues": { + ":and_0_a_eq": { + "N": 1, + }, + ":and_1_b_eq": { + "N": 2, + }, + }, +} +`; + +exports[`DynamoDB expression transforms grouping an or inside an and 1`] = ` +{ + "expression": "(((#a = :and_0_or_0_a_eq) OR (#b = :and_0_or_1_b_eq)) AND (#c = :and_1_c_eq))", + "expressionNames": { + "#a": "a", + "#b": "b", + "#c": "c", + }, + "expressionValues": { + ":and_0_or_0_a_eq": { + "N": 1, + }, + ":and_0_or_1_b_eq": { + "N": 2, + }, + ":and_1_c_eq": { + "N": 3, + }, + }, +} +`; + +exports[`DynamoDB expression transforms grouping an or of two members 1`] = ` +{ + "expression": "((#a = :or_0_a_eq) OR (#b = :or_1_b_eq))", + "expressionNames": { + "#a": "a", + "#b": "b", + }, + "expressionValues": { + ":or_0_a_eq": { + "N": 1, + }, + ":or_1_b_eq": { + "N": 2, + }, + }, +} +`; + +exports[`DynamoDB expression transforms grouping not inside an and 1`] = ` +{ + "expression": "((NOT (#a = :and_0_not_a_eq)) AND (#b = :and_1_b_eq))", + "expressionNames": { + "#a": "a", + "#b": "b", + }, + "expressionValues": { + ":and_0_not_a_eq": { + "N": 1, + }, + ":and_1_b_eq": { + "N": 2, + }, + }, +} +`; + +exports[`DynamoDB expression transforms grouping not of a group 1`] = ` +{ + "expression": "(NOT ((#a = :not_and_0_a_eq) AND (#b = :not_and_1_b_eq)))", + "expressionNames": { + "#a": "a", + "#b": "b", + }, + "expressionValues": { + ":not_and_0_a_eq": { + "N": 1, + }, + ":not_and_1_b_eq": { + "N": 2, + }, + }, +} +`; + +exports[`DynamoDB expression transforms grouping not of a not 1`] = ` +{ + "expression": "(NOT (NOT (#a = :not_not_a_eq)))", + "expressionNames": { + "#a": "a", + }, + "expressionValues": { + ":not_not_a_eq": { + "N": 1, + }, + }, +} +`; + +exports[`DynamoDB expression transforms grouping not of a single comparison 1`] = ` +{ + "expression": "(NOT (#a = :not_a_eq))", + "expressionNames": { + "#a": "a", + }, + "expressionValues": { + ":not_a_eq": { + "N": 1, + }, + }, +} +`; + +exports[`DynamoDB expression transforms grouping two fields at the top level are joined without being wrapped 1`] = ` +{ + "expression": "(#a = :a_eq) AND (#b = :b_eq)", + "expressionNames": { + "#a": "a", + "#b": "b", + }, + "expressionValues": { + ":a_eq": { + "N": 1, + }, + ":b_eq": { + "N": 2, + }, + }, +} +`; + +exports[`DynamoDB expression transforms grouping value names carry every level of the nesting 1`] = ` +{ + "expression": "(#a = :and_0_or_0_and_0_a_eq)", + "expressionNames": { + "#a": "a", + }, + "expressionValues": { + ":and_0_or_0_and_0_a_eq": { + "N": 1, + }, + }, +} +`; + +exports[`DynamoDB expression transforms in a single value 1`] = ` +{ + "expression": "(#v IN (:v_in_0))", + "expressionNames": { + "#v": "v", + }, + "expressionValues": { + ":v_in_0": { + "S": "a", + }, + }, +} +`; + +exports[`DynamoDB expression transforms in separates its values with a comma and a space 1`] = ` +{ + "expression": "(#v IN (:v_in_0, :v_in_1))", + "expressionNames": { + "#v": "v", + }, + "expressionValues": { + ":v_in_0": { + "S": "a", + }, + ":v_in_1": { + "S": "b", + }, + }, +} +`; + +exports[`DynamoDB expression transforms size combines with a function 1`] = ` +{ + "expression": "(contains(size(#v),:v_size_contains))", + "expressionNames": { + "#v": "v", + }, + "expressionValues": { + ":v_size_contains": { + "S": "x", + }, + }, +} +`; + +exports[`DynamoDB expression transforms size combines with a range 1`] = ` +{ + "expression": "(size(#v) BETWEEN :v_size_between_start AND :v_size_between_end)", + "expressionNames": { + "#v": "v", + }, + "expressionValues": { + ":v_size_between_end": { + "N": 3, + }, + ":v_size_between_start": { + "N": 1, + }, + }, +} +`; + +exports[`DynamoDB expression transforms size combines with attributeExists 1`] = ` +{ + "expression": "(attribute_exists(size(#v)))", + "expressionNames": { + "#v": "v", + }, + "expressionValues": {}, +} +`; + +exports[`DynamoDB expression transforms size compares the size of the attribute 1`] = ` +{ + "expression": "(size(#v) > :v_size_gt)", + "expressionNames": { + "#v": "v", + }, + "expressionValues": { + ":v_size_gt": { + "N": 2, + }, + }, +} +`; + +exports[`DynamoDB expression transforms size nesting size names the value again without nesting the call 1`] = ` +{ + "expression": "(size(#v) > :v_size_size_gt)", + "expressionNames": { + "#v": "v", + }, + "expressionValues": { + ":v_size_size_gt": { + "N": 1, + }, + }, +} +`; + +exports[`DynamoDB expression transforms size two comparisons on one size 1`] = ` +{ + "expression": "((size(#v) > :v_size_gt) AND (size(#v) < :v_size_lt))", + "expressionNames": { + "#v": "v", + }, + "expressionValues": { + ":v_size_gt": { + "N": 2, + }, + ":v_size_lt": { + "N": 5, + }, + }, +} +`; + +exports[`DynamoDB expression transforms the shape of the result a condition expression is a JSON string 1`] = `"{"expression":"(attribute_not_exists(#id))","expressionNames":{"#id":"id"},"expressionValues":{}}"`; + +exports[`DynamoDB expression transforms the shape of the result a filter expression is a JSON string 1`] = `"{"expression":"(contains(#content,:content_contains))","expressionNames":{"#content":"content"},"expressionValues":{":content_contains":{"S":"foo"}}}"`; + +exports[`DynamoDB expression transforms the type of an operand a boolean 1`] = ` +{ + "expression": "(#v = :v_eq)", + "expressionNames": { + "#v": "v", + }, + "expressionValues": { + ":v_eq": { + "BOOL": true, + }, + }, +} +`; + +exports[`DynamoDB expression transforms the type of an operand a fractional number 1`] = ` +{ + "expression": "(#v = :v_eq)", + "expressionNames": { + "#v": "v", + }, + "expressionValues": { + ":v_eq": { + "N": 1.5, + }, + }, +} +`; + +exports[`DynamoDB expression transforms the type of an operand a list 1`] = ` +{ + "expression": "(#v = :v_eq)", + "expressionNames": { + "#v": "v", + }, + "expressionValues": { + ":v_eq": { + "L": [ + { + "N": 1, + }, + { + "N": 2, + }, + ], + }, + }, +} +`; + +exports[`DynamoDB expression transforms the type of an operand a number keeps its numeric type 1`] = ` +{ + "expression": "(#v = :v_eq)", + "expressionNames": { + "#v": "v", + }, + "expressionValues": { + ":v_eq": { + "N": 10, + }, + }, +} +`; + +exports[`DynamoDB expression transforms the type of an operand an object 1`] = ` +{ + "expression": "(#v = :v_eq)", + "expressionNames": { + "#v": "v", + }, + "expressionValues": { + ":v_eq": { + "M": { + "a": { + "N": 1, + }, + }, + }, + }, +} +`; + +exports[`DynamoDB expression transforms the type of an operand null 1`] = ` +{ + "expression": "(#v = :v_eq)", + "expressionNames": { + "#v": "v", + }, + "expressionValues": { + ":v_eq": { + "NULL": null, + }, + }, +} +`; diff --git a/__tests__/index.test.js b/__tests__/index.test.js index 260e6e2..22a1e53 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -50,6 +50,9 @@ describe("dynamodb helpers", () => { test("boolean", async () => { await checkValid(`util.dynamodb.toDynamoDB(true)`); }); + test("null", async () => { + await checkValid(`util.dynamodb.toDynamoDB(null)`); + }); }); test("toString", async () => { diff --git a/__tests__/transform.test.js b/__tests__/transform.test.js new file mode 100644 index 0000000..ceb715d --- /dev/null +++ b/__tests__/transform.test.js @@ -0,0 +1,216 @@ +// `util.transform` turns a filter object into a DynamoDB expression. Both entry points produced +// byte identical output for every input recorded from AWS, so most cases below go through +// `toDynamoDBFilterExpression` and a separate block covers the two agreeing. +// +// AWS orders the keys of `expressionNames` and `expressionValues` by nothing in particular, and not +// even consistently between runs, so these tests parse the result before snapshotting; jest then +// serialises the keys in sorted order. The `expression` string itself is compared verbatim, and the +// two cases under "the shape of the result" keep the raw string to pin the serialisation. +import { checkValid } from "./helpers"; + +const parsed = (result) => JSON.parse(result); + +const filter = (source) => checkValid(`util.transform.toDynamoDBFilterExpression(${source})`, {}, parsed); +const condition = (source) => checkValid(`util.transform.toDynamoDBConditionExpression(${source})`, {}, parsed); +// AWS answers a filter it cannot render with `null` rather than with an error +const rejected = (source) => checkValid(`util.transform.toDynamoDBFilterExpression(${source})`); + +describe("DynamoDB expression transforms", () => { + describe("the shape of the result", () => { + test("a filter expression is a JSON string", async () => { + await checkValid(`util.transform.toDynamoDBFilterExpression({ content: { contains: "foo" } })`); + }); + + test("a condition expression is a JSON string", async () => { + await checkValid(`util.transform.toDynamoDBConditionExpression({ and: [{ id: { attributeExists: false } }] })`); + }); + }); + + describe("comparison operators", () => { + test("eq", async () => await filter(`{ v: { eq: "x" } }`)); + test("ne", async () => await filter(`{ v: { ne: "x" } }`)); + test("lt", async () => await filter(`{ v: { lt: "x" } }`)); + test("le", async () => await filter(`{ v: { le: "x" } }`)); + test("gt", async () => await filter(`{ v: { gt: "x" } }`)); + test("ge", async () => await filter(`{ v: { ge: "x" } }`)); + }); + + describe("the type of an operand", () => { + test("a number keeps its numeric type", async () => await filter(`{ v: { eq: 10 } }`)); + test("a fractional number", async () => await filter(`{ v: { eq: 1.5 } }`)); + test("a boolean", async () => await filter(`{ v: { eq: true } }`)); + test("null", async () => await filter(`{ v: { eq: null } }`)); + test("an object", async () => await filter(`{ v: { eq: { a: 1 } } }`)); + test("a list", async () => await filter(`{ v: { eq: [1, 2] } }`)); + }); + + describe("function operators", () => { + test("contains leaves no space after the comma", async () => await filter(`{ v: { contains: "x" } }`)); + test("notContains", async () => await filter(`{ v: { notContains: "x" } }`)); + test("beginsWith", async () => await filter(`{ v: { beginsWith: "x" } }`)); + }); + + describe("between", () => { + test("names its bounds start and end", async () => await filter(`{ v: { between: ["1", "9"] } }`)); + test("numeric bounds", async () => await filter(`{ v: { between: [1, 9] } }`)); + test("ignores anything past the second bound", async () => await filter(`{ v: { between: ["1", "9", "3"] } }`)); + }); + + describe("in", () => { + test("separates its values with a comma and a space", async () => await filter(`{ v: { in: ["a", "b"] } }`)); + test("a single value", async () => await filter(`{ v: { in: ["a"] } }`)); + }); + + describe("attributeExists", () => { + test("true asks whether the attribute exists", async () => await filter(`{ v: { attributeExists: true } }`)); + test("false asks whether it does not", async () => await filter(`{ v: { attributeExists: false } }`)); + }); + + describe("attributeType", () => { + test("every friendly type name becomes its DynamoDB type code", async () => { + await filter(`{ + a: { attributeType: "_null" }, + b: { attributeType: "string" }, + c: { attributeType: "stringSet" }, + d: { attributeType: "number" }, + e: { attributeType: "numberSet" }, + f: { attributeType: "binary" }, + g: { attributeType: "binarySet" }, + h: { attributeType: "boolean" }, + i: { attributeType: "list" }, + j: { attributeType: "map" }, + }`); + }); + }); + + describe("size", () => { + test("compares the size of the attribute", async () => await filter(`{ v: { size: { gt: 2 } } }`)); + test("combines with a range", async () => await filter(`{ v: { size: { between: [1, 3] } } }`)); + test("combines with a function", async () => await filter(`{ v: { size: { contains: "x" } } }`)); + test("combines with attributeExists", async () => await filter(`{ v: { size: { attributeExists: true } } }`)); + test("two comparisons on one size", async () => await filter(`{ v: { size: { gt: 2, lt: 5 } } }`)); + test("nesting size names the value again without nesting the call", async () => { + await filter(`{ v: { size: { size: { gt: 1 } } } }`); + }); + }); + + describe("grouping", () => { + test("two fields at the top level are joined without being wrapped", async () => { + await filter(`{ a: { eq: 1 }, b: { eq: 2 } }`); + }); + + test("a field carrying two operators is wrapped", async () => await filter(`{ v: { ge: 1, le: 5 } }`)); + + test("an and of one member is not wrapped", async () => await filter(`{ and: [{ a: { eq: 1 } }] }`)); + + test("an and of two members is wrapped", async () => { + await filter(`{ and: [{ a: { eq: 1 } }, { b: { eq: 2 } }] }`); + }); + + test("an or of two members", async () => await filter(`{ or: [{ a: { eq: 1 } }, { b: { eq: 2 } }] }`)); + + test("not of a single comparison", async () => await filter(`{ not: { a: { eq: 1 } } }`)); + + test("not of a group", async () => await filter(`{ not: { and: [{ a: { eq: 1 } }, { b: { eq: 2 } }] } }`)); + + test("not of a not", async () => await filter(`{ not: { not: { a: { eq: 1 } } } }`)); + + test("not inside an and", async () => await filter(`{ and: [{ not: { a: { eq: 1 } } }, { b: { eq: 2 } }] }`)); + + test("an and inside an or", async () => { + await filter(`{ or: [{ and: [{ a: { eq: 1 } }, { b: { eq: 2 } }] }, { c: { eq: 3 } }] }`); + }); + + test("an or inside an and", async () => { + await filter(`{ and: [{ or: [{ a: { eq: 1 } }, { b: { eq: 2 } }] }, { c: { eq: 3 } }] }`); + }); + + test("value names carry every level of the nesting", async () => { + await filter(`{ and: [{ or: [{ and: [{ a: { eq: 1 } }] }] }] }`); + }); + + test("a field beside a group", async () => await filter(`{ a: { eq: 1 }, and: [{ b: { eq: 2 } }] }`)); + + test("a field name is used verbatim, dots included", async () => await filter(`{ "a.b": { eq: 1 } }`)); + }); + + describe("filters that render no expression", () => { + test("an empty filter", async () => await filter(`{}`)); + + test("an empty group", async () => await filter(`{ and: [] }`)); + + test("a field without operators keeps its name", async () => await filter(`{ v: {} }`)); + + test("a size without operators keeps the name of its field", async () => await filter(`{ v: { size: {} } }`)); + + test("not of an empty filter", async () => await filter(`{ not: {} }`)); + + test("an empty list of values", async () => await filter(`{ v: { in: [] } }`)); + + // an empty member is joined like any other, which leaves the joiner with nothing after it + test("an empty field beside a comparison leaves a dangling joiner", async () => { + await filter(`{ a: { eq: 1 }, b: {} }`); + }); + + test("an empty member of a group leaves a dangling joiner", async () => { + await filter(`{ and: [{ a: { eq: 1 } }, {}] }`); + }); + }); + + describe("filters that cannot be rendered", () => { + test("no filter at all", async () => await rejected(``)); + test("null", async () => await rejected(`null`)); + test("an array", async () => await rejected(`[]`)); + test("a string", async () => await rejected(`"string"`)); + test("an unknown operator", async () => await rejected(`{ v: { foo: 1 } }`)); + test("a field holding a value instead of operators", async () => await rejected(`{ v: "x" }`)); + test("a field nested in a field", async () => await rejected(`{ a: { b: { eq: 1 } } }`)); + test("an and given a map instead of a list", async () => await rejected(`{ and: { a: { eq: 1 } } }`)); + test("an and given null", async () => await rejected(`{ and: null }`)); + test("a group holding a null member", async () => await rejected(`{ and: [{ a: { eq: 1 } }, null] }`)); + test("a not given a list", async () => await rejected(`{ not: [{ a: { eq: 1 } }] }`)); + test("a range with a single bound", async () => await rejected(`{ v: { between: [1] } }`)); + test("a range that is not a list", async () => await rejected(`{ v: { between: "x" } }`)); + test("a list of values that is not a list", async () => await rejected(`{ v: { in: "x" } }`)); + test("attributeExists given something other than a boolean", async () => await rejected(`{ v: { attributeExists: "yes" } }`)); + test("attributeType given a type code instead of a type name", async () => await rejected(`{ v: { attributeType: "S" } }`)); + test("attributeType given an unknown name", async () => await rejected(`{ v: { attributeType: "bogus" } }`)); + test("size given a value instead of operators", async () => await rejected(`{ v: { size: 3 } }`)); + }); + + describe("both entry points render a filter the same way", () => { + const SHAPES = { + "a comparison": `{ content: { eq: "x" } }`, + "a group of two members": `{ and: [{ content: { eq: "x" } }, { version: { attributeExists: true } }] }`, + "a negated range": `{ not: { id: { between: ["1", "9"] } } }`, + }; + + for (const [name, source] of Object.entries(SHAPES)) { + test(`${name} as a filter`, async () => await filter(source)); + test(`${name} as a condition`, async () => await condition(source)); + } + }); + + // the write conditions and list filter the Amplify GraphQL transformer generates for a model + describe("Amplify model operations", () => { + test("the condition guarding a create", async () => { + await condition(`{ and: [{ id: { attributeExists: false } }] }`); + }); + + test("the condition guarding an update", async () => { + await condition(`{ and: [{ id: { attributeExists: true } }, { _version: { eq: 1 } }] }`); + }); + + test("a list filter matching content", async () => { + await filter(`{ content: { contains: "foo" } }`); + }); + + test("a list filter combining content and a flag", async () => { + await filter(`{ and: [{ content: { contains: "a" } }, { done: { eq: true } }] }`); + }); + + test("a list filter combining a prefix and a range", async () => { + await filter(`{ or: [{ content: { beginsWith: "a" } }, { id: { between: ["1", "9"] } }] }`); + }); + }); +}); diff --git a/index.js b/index.js index cf5c2d9..849f5f1 100644 --- a/index.js +++ b/index.js @@ -4,6 +4,9 @@ import { AppSyncUserError } from './errors.js' export const dynamodbUtils = { toDynamoDB: function(value) { + if (value === null) { + return this.toNull(); + } if (typeof (value) === "number") { return this.toNumber(value); } else if (typeof (value) === "string") { @@ -115,8 +118,6 @@ export const dynamodbUtils = { }, } -const FILTER_CONTAINS = "contains"; - // The strings `util.authType()` returns, see // https://docs.aws.amazon.com/appsync/latest/devguide/resolver-util-reference.html const AUTH_TYPE_API_KEY = "API Key Authorization"; @@ -220,216 +221,199 @@ export const util = { }, }, transform: { - toDynamoDBFilterExpression: function(value) { - const items = Object.entries(value); - if (items.length != 1) { - throw new Error("invalid structure, should have one entry"); - } - - const [key, filter] = items[0]; - - const filterItems = Object.entries(filter); - if (filterItems.length !== 1) { - throw new Error("invalid structure, should have one filter expression"); - } - - - const [filterType, contents] = filterItems[0]; - const expressionName = `#${key}`; - const expressionValue = `:${key}_${filterType}`; - - let expression; - let expressionNames = {}; - let expressionValues = {}; - switch (filterType) { - case FILTER_CONTAINS: - expression = `(contains(${expressionName},${expressionValue}))`; - expressionNames[expressionName] = key; - expressionValues[expressionValue] = util.dynamodb.toDynamoDB(contents); - break; - default: - throw new Error(`Not implemented for ${filterType}`); - - } - - return JSON.stringify({ expression, expressionNames, expressionValues }); - + toDynamoDBFilterExpression: function(filter) { + return transformToExpression(filter); }, - toDynamoDBConditionExpression(condition) { - const result = generateFilterExpression(condition); - return JSON.stringify({ - expression: result.expressions.join(' ').trim(), - expressionNames: result.expressionNames, - // upstream is missing this value: https://github.com/aws-amplify/amplify-cli/blob/5cc1b556d8081421dc68ee264dac02d5660ffee7/packages/amplify-appsync-simulator/src/velocity/util/transform/index.ts#L11 - expressionValues: result.expressionValues, - }); + toDynamoDBConditionExpression: function(condition) { + return transformToExpression(condition); }, }, dynamodb: dynamodbUtils, rds: { toJsonObject }, }; -// embedded here because imports don't yet work -const OPERATOR_MAP = { - ne: '<>', - eq: '=', - lt: '<', - le: '<=', - gt: '>', - ge: '>=', - in: 'contains', +// Both entry points of `util.transform` build a DynamoDB expression out of the same filter object. +// Every case recorded from AWS produced byte identical output for the two of them, so they share +// one builder. An attribute is referenced as `#` and a value placeholder is named after the +// nesting it sits in, the field and the operator, as in `:and_0_content_eq`. + +const COMPARISON_OPERATORS = { + eq: "=", + ne: "<>", + lt: "<", + le: "<=", + gt: ">", + ge: ">=", }; -const FUNCTION_MAP = { - contains: 'contains', - notContains: 'NOT contains', - beginsWith: 'begins_with', +// rendered as `(,)`, without a space after the comma +const FUNCTION_OPERATORS = { + contains: "contains", + notContains: "NOT contains", + beginsWith: "begins_with", }; -export function generateFilterExpression(filter, prefix, parent) { - const expr = Object.entries(filter).reduce( - (sum, [name, value]) => { - let subExpr = { - expressions: [], - expressionNames: {}, - expressionValues: {}, - }; - const fieldName = createExpressionFieldName(parent); - const filedValueName = createExpressionValueName(parent, name, prefix); - - switch (name) { - case 'or': - case 'and': { - const JOINER = name === 'or' ? 'OR' : 'AND'; - if (Array.isArray(value)) { - subExpr = scopeExpression( - value.reduce((expr, subFilter, idx) => { - const newExpr = generateFilterExpression(subFilter, [prefix, name, idx].filter((i) => i !== null).join('_')); - return merge(expr, newExpr, JOINER); - }, subExpr), - ); - } else { - subExpr = generateFilterExpression(value, [prefix, name].filter((val) => val !== null).join('_')); - } - break; - } - case 'not': { - subExpr = scopeExpression(generateFilterExpression(value, [prefix, name].filter((val) => val !== null).join('_'))); - subExpr.expressions.unshift('NOT'); - break; - } - case 'between': { - const expr1 = createExpressionValueName(parent, 'between_1', prefix); - const expr2 = createExpressionValueName(parent, 'between_2', prefix); - const exprName = createExpressionName(parent); - const subExprExpr = `${createExpressionFieldName(parent)} BETWEEN ${expr1} AND ${expr2}`; - const exprValues = { - ...createExpressionValue(parent, 'between_1', value[0], prefix), - ...createExpressionValue(parent, 'between_2', value[1], prefix), - }; - subExpr = { - expressions: [subExprExpr], - expressionNames: exprName, - expressionValues: exprValues, - }; - break; - } - case 'ne': - case 'eq': - case 'gt': - case 'ge': - case 'lt': - case 'le': { - const operator = OPERATOR_MAP[name]; - subExpr = { - expressions: [`(${fieldName} ${operator} ${filedValueName})`], - expressionNames: createExpressionName(parent), - expressionValues: createExpressionValue(parent, name, value, prefix), - }; - break; - } - case 'attributeExists': { - const existsName = value === true ? 'attribute_exists' : 'attribute_not_exists'; - subExpr = { - expressions: [`(${existsName}(${fieldName}))`], - expressionNames: createExpressionName(parent), - expressionValues: [], - }; - break; - } - case 'contains': - case 'notContains': - case 'beginsWith': { - const functionName = FUNCTION_MAP[name]; - subExpr = { - expressions: [`(${functionName}(${fieldName}, ${filedValueName}))`], - expressionNames: createExpressionName(parent), - expressionValues: createExpressionValue(parent, name, value, prefix), - }; - break; - } - case 'in': { - const operatorName = OPERATOR_MAP[name]; - subExpr = { - expressions: [`(${operatorName}(${filedValueName}, ${fieldName}))`], - expressionNames: createExpressionName(parent), - expressionValues: createExpressionValue(parent, name, value, prefix), - }; - break; - } - default: - subExpr = scopeExpression(generateFilterExpression(value, prefix, name)); - } - return merge(sum, subExpr); - }, - { - expressions: [], - expressionNames: {}, - expressionValues: {}, - }, - ); +// `attributeType` takes the friendly name of a DynamoDB type rather than the type code itself +const ATTRIBUTE_TYPE_CODES = { + _null: "NULL", + string: "S", + stringSet: "SS", + number: "N", + numberSet: "NS", + binary: "B", + binarySet: "BS", + boolean: "BOOL", + list: "L", + map: "M", +}; - return expr; -} +// A filter AWS cannot turn into an expression, an unknown operator or an operand of the wrong type +// for its operator, comes back as `null` rather than as an error. The builder throws this to unwind +// and the entry point turns it back into `null`. +class InvalidFilter extends Error {} + +const isFilterObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value); + +const hasOperator = (operators, operator) => Object.hasOwn(operators, operator); -function merge(expr1, expr2, joinCondition = 'AND') { - if (!expr2.expressions.length) { - return expr1; +function transformToExpression(filter) { + let node; + try { + node = buildFilter(filter, [], false); + } catch (error) { + if (error instanceof InvalidFilter) { + return null; + } + throw error; } + return JSON.stringify({ + expression: node.expression, + expressionNames: node.expressionNames, + expressionValues: node.expressionValues, + }); +} - const res = { - expressions: [...expr1.expressions, expr1.expressions.length ? joinCondition : '', ...expr2.expressions], - expressionNames: { ...expr1.expressionNames, ...expr2.expressionNames }, - expressionValues: { ...expr1.expressionValues, ...expr2.expressionValues }, +// `wrap` parenthesises a group that holds more than one member. The filter as a whole is never +// wrapped, every group below it is. Members that render to nothing are joined all the same, which +// is what leaves a dangling joiner on AWS for a filter such as `{a: {eq: 1}, b: {}}`. +function joinFilters(nodes, joiner, wrap) { + const expression = nodes.map((node) => node.expression).join(` ${joiner} `); + return { + expression: wrap && nodes.length > 1 ? `(${expression})` : expression, + expressionNames: Object.assign({}, ...nodes.map((node) => node.expressionNames)), + expressionValues: Object.assign({}, ...nodes.map((node) => node.expressionValues)), }; - return res; } -function createExpressionValueName(fieldName, op, prefix) { - return `:${[prefix, fieldName, op].filter((name) => name).join('_')}`; +// `path` is the nesting the value placeholders are named after: the `and`/`or` groups with their +// index, `not`, then the field and one `size` per level of size nesting. +function buildFilter(filter, path, wrap) { + if (!isFilterObject(filter)) { + throw new InvalidFilter(); + } + const nodes = Object.entries(filter).map(([key, value]) => { + if (key === "and" || key === "or") { + return buildGroup(key, value, path); + } + if (key === "not") { + return buildNegation(value, path); + } + return buildField(key, value, [...path, key], false); + }); + return joinFilters(nodes, "AND", wrap); } -function createExpressionName(fieldName) { - return { - [createExpressionFieldName(fieldName)]: fieldName, - }; + +function buildGroup(key, members, path) { + // AWS takes the list form only, even though its own types also describe a map + if (!Array.isArray(members)) { + throw new InvalidFilter(); + } + const nodes = members.map((member, index) => buildFilter(member, [...path, key, index], false)); + return joinFilters(nodes, key === "or" ? "OR" : "AND", true); } -function createExpressionFieldName(fieldName) { - return `#${fieldName}`; +function buildNegation(filter, path) { + const node = buildFilter(filter, [...path, "not"], true); + return { ...node, expression: `(NOT ${node.expression})` }; } -function createExpressionValue(fieldName, op, value, prefix) { - const exprName = createExpressionValueName(fieldName, op, prefix); - const exprValue = dynamodbUtils.toDynamoDB(value); - return { - [`${exprName}`]: exprValue, - }; + +function buildField(field, operators, path, sized) { + if (!isFilterObject(operators)) { + throw new InvalidFilter(); + } + const name = `#${field}`; + const target = sized ? `size(${name})` : name; + const nodes = Object.entries(operators).map(([operator, operand]) => { + if (operator === "size") { + // nesting `size` deepens the placeholder but never nests the call itself + return buildField(field, operand, [...path, operator], true); + } + return buildOperator(target, operator, operand, `:${[...path, operator].join("_")}`); + }); + const node = joinFilters(nodes, "AND", true); + // a field contributes its name even when it carries no operator at all + return { ...node, expressionNames: { [name]: field, ...node.expressionNames } }; } -function scopeExpression(expr) { - const result = { ...expr }; - result.expressions = result.expressions.filter((e) => !!e); - if (result.expressions.length > 1) { - result.expressions = ['(' + result.expressions.join(' ') + ')']; +function buildOperator(target, operator, operand, value) { + const leaf = (expression, expressionValues = {}) => ({ + expression, + expressionNames: {}, + expressionValues, + }); + + if (hasOperator(COMPARISON_OPERATORS, operator)) { + return leaf(`(${target} ${COMPARISON_OPERATORS[operator]} ${value})`, { + [value]: dynamodbUtils.toDynamoDB(operand), + }); + } + if (hasOperator(FUNCTION_OPERATORS, operator)) { + return leaf(`(${FUNCTION_OPERATORS[operator]}(${target},${value}))`, { + [value]: dynamodbUtils.toDynamoDB(operand), + }); + } + + switch (operator) { + case "between": { + // anything the range does not need is ignored, but it does need both ends + if (!Array.isArray(operand) || operand.length < 2) { + throw new InvalidFilter(); + } + return leaf(`(${target} BETWEEN ${value}_start AND ${value}_end)`, { + [`${value}_start`]: dynamodbUtils.toDynamoDB(operand[0]), + [`${value}_end`]: dynamodbUtils.toDynamoDB(operand[1]), + }); + } + case "in": { + if (!Array.isArray(operand)) { + throw new InvalidFilter(); + } + // an empty list leaves AWS with no operand to render and it emits this literal + if (operand.length === 0) { + return leaf("(null)"); + } + const values = operand.map((_operand, index) => `${value}_${index}`); + return leaf( + // the only operator whose comma is followed by a space + `(${target} IN (${values.join(", ")}))`, + Object.fromEntries(values.map((name, index) => [name, dynamodbUtils.toDynamoDB(operand[index])])), + ); + } + case "attributeExists": { + if (typeof operand !== "boolean") { + throw new InvalidFilter(); + } + return leaf(`(${operand ? "attribute_exists" : "attribute_not_exists"}(${target}))`); + } + case "attributeType": { + if (typeof operand !== "string" || !hasOperator(ATTRIBUTE_TYPE_CODES, operand)) { + throw new InvalidFilter(); + } + return leaf(`(attribute_type(${target},${value}))`, { + [value]: dynamodbUtils.toDynamoDB(ATTRIBUTE_TYPE_CODES[operand]), + }); + } + default: + throw new InvalidFilter(); } - return result; }