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