diff --git a/README.md b/README.md index 38c9d94..78c2654 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ This package provides an implementation for the `@aws-appsync/utils` package tha ## Changelog: +- v0.1.7: complete the `@aws-appsync/utils/dynamodb` module with `query`, `sync`, the batch and transact helpers, the set helpers, `operations.remove` and `operations.updateListItem`, and the arguments the existing helpers dropped (`filter`, `projection`, `condition`, `consistentRead`, `_version` and the rest); add `util.transform.toSubscriptionFilter`; `util.dynamodb.fromS3ObjectJson` now parses instead of throwing - 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` diff --git a/__tests__/__snapshots__/index.test.js.snap b/__tests__/__snapshots__/index.test.js.snap index 2b77f41..e2f55c1 100644 --- a/__tests__/__snapshots__/index.test.js.snap +++ b/__tests__/__snapshots__/index.test.js.snap @@ -1,6 +1,125 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`DynamoDB module functions get 1`] = ` +exports[`DynamoDB module functions batch delete 1`] = ` +{ + "operation": "BatchDeleteItem", + "tables": { + "post": [ + { + "id": { + "S": "one", + }, + }, + ], + }, +} +`; + +exports[`DynamoDB module functions batch get 1`] = ` +{ + "operation": "BatchGetItem", + "tables": { + "post": { + "consistentRead": false, + "keys": [ + { + "id": { + "S": "1", + }, + }, + { + "id": { + "S": "2", + }, + }, + ], + "projection": { + "expression": "#expName_1, #expName_2", + "expressionNames": { + "#expName_1": "id", + "#expName_2": "name", + }, + }, + }, + }, +} +`; + +exports[`DynamoDB module functions batch get without options 1`] = ` +{ + "operation": "BatchGetItem", + "tables": { + "post": { + "keys": [ + { + "id": { + "S": "1", + }, + }, + ], + }, + }, +} +`; + +exports[`DynamoDB module functions batch put 1`] = ` +{ + "operation": "BatchPutItem", + "tables": { + "post": [ + { + "id": { + "S": "one", + }, + "value": { + "S": "x", + }, + }, + ], + }, +} +`; + +exports[`DynamoDB module functions get a consistent read of some attributes 1`] = ` +{ + "consistentRead": true, + "key": { + "id": { + "S": "1", + }, + }, + "operation": "GetItem", + "projection": { + "expression": "#expName_1, #expName_2.#expName_3", + "expressionNames": { + "#expName_1": "id", + "#expName_2": "a", + "#expName_3": "b", + }, + }, +} +`; + +exports[`DynamoDB module functions get a projection reusing a path segment 1`] = ` +{ + "key": { + "id": { + "S": "1", + }, + }, + "operation": "GetItem", + "projection": { + "expression": "#expName_1.#expName_2, #expName_1.#expName_3", + "expressionNames": { + "#expName_1": "a", + "#expName_2": "b", + "#expName_3": "c", + }, + }, +} +`; + +exports[`DynamoDB module functions get by key 1`] = ` { "key": { "id": { @@ -11,7 +130,7 @@ exports[`DynamoDB module functions get 1`] = ` } `; -exports[`DynamoDB module functions put 1`] = ` +exports[`DynamoDB module functions put an item 1`] = ` { "attributeValues": { "value": { @@ -27,7 +146,175 @@ exports[`DynamoDB module functions put 1`] = ` } `; -exports[`DynamoDB module functions remove 1`] = ` +exports[`DynamoDB module functions put guarded by a condition 1`] = ` +{ + "attributeValues": { + "v": { + "N": 1, + }, + }, + "condition": { + "expression": "(#version = :version_eq)", + "expressionNames": { + "#version": "version", + }, + "expressionValues": { + ":version_eq": { + "N": 1, + }, + }, + }, + "key": { + "id": { + "S": "1", + }, + }, + "operation": "PutItem", +} +`; + +exports[`DynamoDB module functions put guarded by an attribute existing 1`] = ` +{ + "attributeValues": { + "v": { + "N": 1, + }, + }, + "condition": { + "expression": "(attribute_exists(#v))", + "expressionNames": { + "#v": "v", + }, + }, + "key": { + "id": { + "S": "1", + }, + }, + "operation": "PutItem", +} +`; + +exports[`DynamoDB module functions put versioned, with a custom partition key and index fields 1`] = ` +{ + "_version": 3, + "attributeValues": { + "v": { + "N": 1, + }, + }, + "customPartitionKey": "cpk", + "key": { + "id": { + "S": "1", + }, + }, + "operation": "PutItem", + "populateIndexFields": true, +} +`; + +exports[`DynamoDB module functions query on a key 1`] = ` +{ + "operation": "Query", + "query": { + "expression": "(#id = :id_eq)", + "expressionNames": { + "#id": "id", + }, + "expressionValues": { + ":id_eq": { + "S": "1", + }, + }, + }, +} +`; + +exports[`DynamoDB module functions query on a partition and a sort key 1`] = ` +{ + "operation": "Query", + "query": { + "expression": "(#a = :a_eq) AND (begins_with(#b,:b_beginsWith))", + "expressionNames": { + "#a": "a", + "#b": "b", + }, + "expressionValues": { + ":a_eq": { + "S": "x", + }, + ":b_beginsWith": { + "S": "y", + }, + }, + }, +} +`; + +exports[`DynamoDB module functions query on a range of sort keys 1`] = ` +{ + "operation": "Query", + "query": { + "expression": "(#sk BETWEEN :sk_between_start AND :sk_between_end)", + "expressionNames": { + "#sk": "sk", + }, + "expressionValues": { + ":sk_between_end": { + "N": 9, + }, + ":sk_between_start": { + "N": 1, + }, + }, + }, +} +`; + +exports[`DynamoDB module functions query with every argument 1`] = ` +{ + "consistentRead": true, + "filter": { + "expression": "(#done = :done_eq)", + "expressionNames": { + "#done": "done", + }, + "expressionValues": { + ":done_eq": { + "BOOL": true, + }, + }, + }, + "index": "gsi1", + "limit": 5, + "nextToken": "t", + "operation": "Query", + "projection": { + "expression": "#expName_1, #expName_2.#expName_3", + "expressionNames": { + "#expName_1": "id", + "#expName_2": "nested", + "#expName_3": "field", + }, + }, + "query": { + "expression": "(#pk = :pk_eq)", + "expressionNames": { + "#pk": "pk", + }, + "expressionValues": { + ":pk_eq": { + "S": "a", + }, + }, + }, + "scanIndexForward": false, + "select": "ALL_ATTRIBUTES", +} +`; + +exports[`DynamoDB module functions remove by key 1`] = ` { "key": { "id": { @@ -38,7 +325,64 @@ exports[`DynamoDB module functions remove 1`] = ` } `; -exports[`DynamoDB module functions scan 1`] = ` +exports[`DynamoDB module functions remove versioned and guarded by a condition 1`] = ` +{ + "_version": 2, + "condition": { + "expression": "(#a = :a_eq)", + "expressionNames": { + "#a": "a", + }, + "expressionValues": { + ":a_eq": { + "N": 1, + }, + }, + }, + "key": { + "id": { + "S": "1", + }, + }, + "operation": "DeleteItem", +} +`; + +exports[`DynamoDB module functions scan of one segment of an index 1`] = ` +{ + "consistentRead": true, + "index": "gsi", + "operation": "Scan", + "segment": 1, + "totalSegments": 4, +} +`; + +exports[`DynamoDB module functions scan with a filter and a projection 1`] = ` +{ + "filter": { + "expression": "(#a = :a_eq)", + "expressionNames": { + "#a": "a", + }, + "expressionValues": { + ":a_eq": { + "N": 1, + }, + }, + }, + "limit": 2, + "operation": "Scan", + "projection": { + "expression": "#expName_1", + "expressionNames": { + "#expName_1": "a", + }, + }, +} +`; + +exports[`DynamoDB module functions scan with a limit and a page token 1`] = ` { "limit": 10, "nextToken": "abc", @@ -46,7 +390,218 @@ exports[`DynamoDB module functions scan 1`] = ` } `; -exports[`DynamoDB module functions sync 1`] = `undefined`; +exports[`DynamoDB module functions scan without any arguments 1`] = ` +{ + "operation": "Scan", +} +`; + +exports[`DynamoDB module functions set helpers toBinarySet 1`] = ` +{ + "BS": [ + "SGVsbG8=", + ], +} +`; + +exports[`DynamoDB module functions set helpers toNumberSet 1`] = ` +{ + "NS": [ + 1, + 2, + 3, + ], +} +`; + +exports[`DynamoDB module functions set helpers toStringSet 1`] = ` +{ + "SS": [ + "a", + "b", + ], +} +`; + +exports[`DynamoDB module functions sync of a delta index with a filter 1`] = ` +{ + "basePartitionKey": "bpk", + "deltaIndexName": "delta", + "filter": { + "expression": "(#a = :a_eq)", + "expressionNames": { + "#a": "a", + }, + "expressionValues": { + ":a_eq": { + "N": 1, + }, + }, + }, + "lastSync": 2, + "limit": 5, + "operation": "Sync", +} +`; + +exports[`DynamoDB module functions sync since the last one 1`] = ` +{ + "lastSync": 1, + "limit": 10, + "nextToken": "abc", + "operation": "Sync", +} +`; + +exports[`DynamoDB module functions transact get 1`] = ` +{ + "operation": "TransactGetItems", + "transactItems": [ + { + "key": { + "id": { + "S": "1", + }, + }, + "projection": { + "expression": "#expName_1", + "expressionNames": { + "#expName_1": "name", + }, + }, + "table": "post", + }, + ], +} +`; + +exports[`DynamoDB module functions transact get without a projection 1`] = ` +{ + "operation": "TransactGetItems", + "transactItems": [ + { + "key": { + "id": { + "S": "1", + }, + }, + "table": "post", + }, + ], +} +`; + +exports[`DynamoDB module functions transact write putting and deleting 1`] = ` +{ + "operation": "TransactWriteItems", + "transactItems": [ + { + "attributeValues": { + "a": { + "N": 1, + }, + }, + "key": { + "id": { + "S": "1", + }, + }, + "operation": "PutItem", + "table": "post", + }, + { + "condition": { + "expression": "(#v = :v_eq)", + "expressionNames": { + "#v": "v", + }, + "expressionValues": { + ":v_eq": { + "N": 1, + }, + }, + }, + "key": { + "id": { + "S": "2", + }, + }, + "operation": "DeleteItem", + "table": "post", + }, + ], +} +`; + +exports[`DynamoDB module functions transact write updating and checking a condition 1`] = ` +{ + "operation": "TransactWriteItems", + "transactItems": [ + { + "key": { + "id": { + "S": "1", + }, + }, + "operation": "UpdateItem", + "table": "t", + "update": { + "expression": "SET #expName_1 = #expName_1 + :expValue_1", + "expressionNames": { + "#expName_1": "a", + }, + "expressionValues": { + ":expValue_1": { + "N": 1, + }, + }, + }, + }, + { + "condition": { + "expression": "(#v = :v_eq)", + "expressionNames": { + "#v": "v", + }, + "expressionValues": { + ":v_eq": { + "N": 1, + }, + }, + }, + "key": { + "id": { + "S": "2", + }, + }, + "operation": "ConditionCheck", + "table": "t", + }, + ], +} +`; + +exports[`DynamoDB module functions update a bare value sets the attribute 1`] = ` +{ + "key": { + "id": { + "S": "1", + }, + }, + "operation": "UpdateItem", + "update": { + "expression": "SET #expName_1 = :expValue_1", + "expressionNames": { + "#expName_1": "isVerified", + }, + "expressionValues": { + ":expValue_1": { + "BOOL": true, + }, + }, + }, +} +`; exports[`DynamoDB module functions update add 1`] = ` { @@ -70,6 +625,28 @@ exports[`DynamoDB module functions update add 1`] = ` } `; +exports[`DynamoDB module functions update an item of a list 1`] = ` +{ + "key": { + "id": { + "S": "1", + }, + }, + "operation": "UpdateItem", + "update": { + "expression": "SET #expName_1[1] = :expValue_1", + "expressionNames": { + "#expName_1": "friends", + }, + "expressionValues": { + ":expValue_1": { + "S": "x", + }, + }, + }, +} +`; + exports[`DynamoDB module functions update append 1`] = ` { "key": { @@ -146,6 +723,28 @@ exports[`DynamoDB module functions update increment 1`] = ` } `; +exports[`DynamoDB module functions update increment by one when given no step 1`] = ` +{ + "key": { + "id": { + "S": "1", + }, + }, + "operation": "UpdateItem", + "update": { + "expression": "SET #expName_1 = #expName_1 + :expValue_1", + "expressionNames": { + "#expName_1": "a", + }, + "expressionValues": { + ":expValue_1": { + "N": 1, + }, + }, + }, +} +`; + exports[`DynamoDB module functions update prepend 1`] = ` { "key": { @@ -178,6 +777,47 @@ exports[`DynamoDB module functions update prepend 1`] = ` } `; +exports[`DynamoDB module functions update remove 1`] = ` +{ + "key": { + "id": { + "S": "1", + }, + }, + "operation": "UpdateItem", + "update": { + "expression": "REMOVE #expName_1", + "expressionNames": { + "#expName_1": "addr", + }, + "expressionValues": {}, + }, +} +`; + +exports[`DynamoDB module functions update removing written before setting 1`] = ` +{ + "key": { + "id": { + "S": "1", + }, + }, + "operation": "UpdateItem", + "update": { + "expression": "SET #expName_1 = #expName_1 + :expValue_1 REMOVE #expName_2", + "expressionNames": { + "#expName_1": "a", + "#expName_2": "b", + }, + "expressionValues": { + ":expValue_1": { + "N": 1, + }, + }, + }, +} +`; + exports[`DynamoDB module functions update replace 1`] = ` { "key": { @@ -204,6 +844,133 @@ exports[`DynamoDB module functions update replace 1`] = ` } `; +exports[`DynamoDB module functions update setting and removing together 1`] = ` +{ + "key": { + "id": { + "S": "1", + }, + }, + "operation": "UpdateItem", + "update": { + "expression": "SET #expName_1 = #expName_1 + :expValue_1 REMOVE #expName_2", + "expressionNames": { + "#expName_1": "a", + "#expName_2": "b", + }, + "expressionValues": { + ":expValue_1": { + "N": 1, + }, + }, + }, +} +`; + +exports[`DynamoDB module functions update two attributes at once 1`] = ` +{ + "key": { + "id": { + "S": "1", + }, + }, + "operation": "UpdateItem", + "update": { + "expression": "SET #expName_1 = #expName_1 + :expValue_1, #expName_2 = :expValue_2", + "expressionNames": { + "#expName_1": "a", + "#expName_2": "b", + }, + "expressionValues": { + ":expValue_1": { + "N": 1, + }, + ":expValue_2": { + "S": "x", + }, + }, + }, +} +`; + +exports[`DynamoDB module functions update two items of one list share an alias 1`] = ` +{ + "key": { + "id": { + "S": "1", + }, + }, + "operation": "UpdateItem", + "update": { + "expression": "SET #expName_1[1] = :expValue_1, #expName_1[2] = :expValue_2", + "expressionNames": { + "#expName_1": "f", + }, + "expressionValues": { + ":expValue_1": { + "S": "x", + }, + ":expValue_2": { + "S": "y", + }, + }, + }, +} +`; + +exports[`DynamoDB module functions update two removals share one clause 1`] = ` +{ + "key": { + "id": { + "S": "1", + }, + }, + "operation": "UpdateItem", + "update": { + "expression": "REMOVE #expName_1, #expName_2", + "expressionNames": { + "#expName_1": "a", + "#expName_2": "b", + }, + "expressionValues": {}, + }, +} +`; + +exports[`DynamoDB module functions update versioned and guarded by a condition 1`] = ` +{ + "_version": 5, + "condition": { + "expression": "(#b = :b_eq)", + "expressionNames": { + "#b": "b", + }, + "expressionValues": { + ":b_eq": { + "N": 2, + }, + }, + }, + "key": { + "id": { + "S": "1", + }, + }, + "operation": "UpdateItem", + "update": { + "expression": "SET #expName_1 = #expName_1 + :expValue_1", + "expressionNames": { + "#expName_1": "a", + }, + "expressionValues": { + ":expValue_1": { + "N": 1, + }, + }, + }, +} +`; + exports[`DynamoDB operations add 1`] = ` { "type": "OPERATION_ADD", @@ -247,6 +1014,12 @@ exports[`DynamoDB operations prepend 1`] = ` } `; +exports[`DynamoDB operations remove 1`] = ` +{ + "type": "OPERATION_REMOVE", +} +`; + exports[`DynamoDB operations replace 1`] = ` { "type": "OPERATION_REPLACE", @@ -268,7 +1041,22 @@ exports[`dynamodb helpers s3 objects four parameter function 1`] = ` } `; -exports[`dynamodb helpers s3 objects fromS3ObjectJson 1`] = `undefined`; +exports[`dynamodb helpers s3 objects fromS3ObjectJson 1`] = ` +{ + "bucket": "bar", + "key": "foo", + "region": "baz", +} +`; + +exports[`dynamodb helpers s3 objects fromS3ObjectJson with a version 1`] = ` +{ + "bucket": "bar", + "key": "foo", + "region": "baz", + "version": "beep", +} +`; exports[`dynamodb helpers s3 objects three parameter function 1`] = ` { diff --git a/__tests__/__snapshots__/transform-subscription.test.js.snap b/__tests__/__snapshots__/transform-subscription.test.js.snap new file mode 100644 index 0000000..396bb14 --- /dev/null +++ b/__tests__/__snapshots__/transform-subscription.test.js.snap @@ -0,0 +1,564 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`subscription filters a single group a field beside an and 1`] = ` +{ + "filterGroup": [ + { + "filters": [ + { + "fieldName": "z", + "operator": "eq", + "value": 0, + }, + { + "fieldName": "a", + "operator": "eq", + "value": 1, + }, + ], + }, + ], +} +`; + +exports[`subscription filters a single group a field path is kept as written 1`] = ` +{ + "filterGroup": [ + { + "filters": [ + { + "fieldName": "loc.addr.country", + "operator": "eq", + "value": "USA", + }, + ], + }, + ], +} +`; + +exports[`subscription filters a single group an and combines its members 1`] = ` +{ + "filterGroup": [ + { + "filters": [ + { + "fieldName": "a", + "operator": "eq", + "value": 1, + }, + { + "fieldName": "b", + "operator": "eq", + "value": 2, + }, + ], + }, + ], +} +`; + +exports[`subscription filters a single group one operator on one field 1`] = ` +{ + "filterGroup": [ + { + "filters": [ + { + "fieldName": "a", + "operator": "eq", + "value": 1, + }, + ], + }, + ], +} +`; + +exports[`subscription filters a single group two fields are combined 1`] = ` +{ + "filterGroup": [ + { + "filters": [ + { + "fieldName": "a", + "operator": "eq", + "value": 1, + }, + { + "fieldName": "b", + "operator": "eq", + "value": 2, + }, + ], + }, + ], +} +`; + +exports[`subscription filters alternatives multiply the groups out an or nested in an and 1`] = ` +{ + "filterGroup": [ + { + "filters": [ + { + "fieldName": "a", + "operator": "eq", + "value": 1, + }, + { + "fieldName": "b", + "operator": "eq", + "value": 2, + }, + ], + }, + { + "filters": [ + { + "fieldName": "a", + "operator": "eq", + "value": 1, + }, + { + "fieldName": "c", + "operator": "eq", + "value": 3, + }, + ], + }, + ], +} +`; + +exports[`subscription filters alternatives multiply the groups out an or splits into a group per member 1`] = ` +{ + "filterGroup": [ + { + "filters": [ + { + "fieldName": "a", + "operator": "eq", + "value": 1, + }, + ], + }, + { + "filters": [ + { + "fieldName": "b", + "operator": "eq", + "value": 2, + }, + ], + }, + ], +} +`; + +exports[`subscription filters alternatives multiply the groups out operators and groups together 1`] = ` +{ + "filterGroup": [ + { + "filters": [ + { + "fieldName": "percentageUp", + "operator": "lte", + "value": 50, + }, + { + "fieldName": "title", + "operator": "ne", + "value": "Book1", + }, + { + "fieldName": "downvotes", + "operator": "gt", + "value": 2000, + }, + { + "fieldName": "author", + "operator": "eq", + "value": "Admin", + }, + ], + }, + { + "filters": [ + { + "fieldName": "percentageUp", + "operator": "lte", + "value": 50, + }, + { + "fieldName": "title", + "operator": "ne", + "value": "Book1", + }, + { + "fieldName": "downvotes", + "operator": "gt", + "value": 2000, + }, + { + "fieldName": "isPublished", + "operator": "eq", + "value": false, + }, + ], + }, + { + "filters": [ + { + "fieldName": "percentageUp", + "operator": "gte", + "value": 20, + }, + { + "fieldName": "title", + "operator": "ne", + "value": "Book1", + }, + { + "fieldName": "downvotes", + "operator": "gt", + "value": 2000, + }, + { + "fieldName": "author", + "operator": "eq", + "value": "Admin", + }, + ], + }, + { + "filters": [ + { + "fieldName": "percentageUp", + "operator": "gte", + "value": 20, + }, + { + "fieldName": "title", + "operator": "ne", + "value": "Book1", + }, + { + "fieldName": "downvotes", + "operator": "gt", + "value": 2000, + }, + { + "fieldName": "isPublished", + "operator": "eq", + "value": false, + }, + ], + }, + ], +} +`; + +exports[`subscription filters alternatives multiply the groups out two operators on one field 1`] = ` +{ + "filterGroup": [ + { + "filters": [ + { + "fieldName": "a", + "operator": "eq", + "value": 1, + }, + ], + }, + { + "filters": [ + { + "fieldName": "a", + "operator": "ne", + "value": 2, + }, + ], + }, + ], +} +`; + +exports[`subscription filters filters that cannot be read a JSON string 1`] = `null`; + +exports[`subscription filters filters that cannot be read an array 1`] = `null`; + +exports[`subscription filters filters that cannot be read no filter at all 1`] = `null`; + +exports[`subscription filters filters that cannot be read null 1`] = `null`; + +exports[`subscription filters filters that produce no group a field with no operator 1`] = ` +{ + "filterGroup": [], +} +`; + +exports[`subscription filters filters that produce no group an empty and 1`] = ` +{ + "filterGroup": [], +} +`; + +exports[`subscription filters filters that produce no group an empty filter 1`] = ` +{ + "filterGroup": [], +} +`; + +exports[`subscription filters ignored fields drop a field 1`] = ` +{ + "filterGroup": [ + { + "filters": [ + { + "fieldName": "b", + "operator": "eq", + "value": 2, + }, + ], + }, + ], +} +`; + +exports[`subscription filters ignored fields drop a field nested in an and 1`] = ` +{ + "filterGroup": [ + { + "filters": [ + { + "fieldName": "a", + "operator": "eq", + "value": 1, + }, + ], + }, + ], +} +`; + +exports[`subscription filters operators are carried through as written a boolean value 1`] = ` +{ + "filterGroup": [ + { + "filters": [ + { + "fieldName": "a", + "operator": "eq", + "value": false, + }, + ], + }, + ], +} +`; + +exports[`subscription filters operators are carried through as written an unknown operator 1`] = ` +{ + "filterGroup": [ + { + "filters": [ + { + "fieldName": "a", + "operator": "bogus", + "value": 1, + }, + ], + }, + ], +} +`; + +exports[`subscription filters operators are carried through as written between 1`] = ` +{ + "filterGroup": [ + { + "filters": [ + { + "fieldName": "a", + "operator": "between", + "value": [ + 1, + 9, + ], + }, + ], + }, + ], +} +`; + +exports[`subscription filters operators are carried through as written containsAny 1`] = ` +{ + "filterGroup": [ + { + "filters": [ + { + "fieldName": "a", + "operator": "containsAny", + "value": [ + "x", + "y", + ], + }, + ], + }, + ], +} +`; + +exports[`subscription filters operators are carried through as written membership 1`] = ` +{ + "filterGroup": [ + { + "filters": [ + { + "fieldName": "a", + "operator": "in", + "value": [ + 1, + 2, + ], + }, + { + "fieldName": "b", + "operator": "notIn", + "value": [ + "x", + ], + }, + ], + }, + ], +} +`; + +exports[`subscription filters operators are carried through as written not is treated as a field 1`] = ` +{ + "filterGroup": [ + { + "filters": [ + { + "fieldName": "not", + "operator": "a", + "value": { + "eq": 1, + }, + }, + ], + }, + ], +} +`; + +exports[`subscription filters operators are carried through as written the comparisons 1`] = ` +{ + "filterGroup": [ + { + "filters": [ + { + "fieldName": "a", + "operator": "le", + "value": 1, + }, + { + "fieldName": "b", + "operator": "lt", + "value": 2, + }, + { + "fieldName": "c", + "operator": "ge", + "value": 3, + }, + { + "fieldName": "d", + "operator": "gt", + "value": 4, + }, + ], + }, + ], +} +`; + +exports[`subscription filters operators are carried through as written the string operators 1`] = ` +{ + "filterGroup": [ + { + "filters": [ + { + "fieldName": "a", + "operator": "contains", + "value": "x", + }, + { + "fieldName": "b", + "operator": "notContains", + "value": "y", + }, + { + "fieldName": "c", + "operator": "beginsWith", + "value": "z", + }, + ], + }, + ], +} +`; + +exports[`subscription filters rules are added as alternatives 1`] = ` +{ + "filterGroup": [ + { + "filters": [ + { + "fieldName": "a", + "operator": "eq", + "value": 1, + }, + { + "fieldName": "r1", + "operator": "gte", + "value": 250, + }, + ], + }, + { + "filters": [ + { + "fieldName": "a", + "operator": "eq", + "value": 1, + }, + { + "fieldName": "r2", + "operator": "eq", + "value": "p", + }, + ], + }, + ], +} +`; + +exports[`subscription filters rules on their own 1`] = ` +{ + "filterGroup": [ + { + "filters": [ + { + "fieldName": "r1", + "operator": "eq", + "value": 1, + }, + ], + }, + ], +} +`; diff --git a/__tests__/index.test.js b/__tests__/index.test.js index 22a1e53..101e230 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -106,33 +106,154 @@ describe("dynamodb helpers", () => { test("four parameter function", async () => { await checkValid(`util.dynamodb.toS3Object("foo", "bar", "baz", "beep")`); }); - test.skip("fromS3ObjectJson", async () => { - await checkValid(`util.dynamodb.fromS3ObjectJson({ "S" : "{ \"s3\" : { \"key\" : \"foo\", \"bucket\" : \"bar\", \"region\" : \"baz\", \"version\" = \"beep\" } }" })`); + test("fromS3ObjectJson", async () => { + await checkValid(`util.dynamodb.fromS3ObjectJson('{"s3":{"key":"foo","bucket":"bar","region":"baz"}}')`); + }); + test("fromS3ObjectJson with a version", async () => { + await checkValid(`util.dynamodb.fromS3ObjectJson('{"s3":{"key":"foo","bucket":"bar","region":"baz","version":"beep"}}')`); }); }); }); describe("DynamoDB module functions", () => { - test("get", async () => { - await checkValid(`ddb.get({ key: { id: "id" }})`); + describe("get", () => { + test("by key", async () => { + await checkValid(`ddb.get({ key: { id: "id" }})`); + }); + + test("a consistent read of some attributes", async () => { + await checkValid(`ddb.get({ key: { id: "1" }, consistentRead: true, projection: ["id", "a.b"] })`); + }); + + test("a projection reusing a path segment", async () => { + await checkValid(`ddb.get({ key: { id: "1" }, projection: ["a.b", "a.c"] })`); + }); }); - test("put", async () => { - await checkValid(`ddb.put({ key: { id: "abc" }, item: { value: 10 }})`); + describe("put", () => { + test("an item", async () => { + await checkValid(`ddb.put({ key: { id: "abc" }, item: { value: 10 }})`); + }); + + test("guarded by a condition", async () => { + await checkValid(`ddb.put({ key: { id: "1" }, item: { v: 1 }, condition: { version: { eq: 1 } } })`); + }); + + // a condition that binds no value carries no expressionValues at all + test("guarded by an attribute existing", async () => { + await checkValid(`ddb.put({ key: { id: "1" }, item: { v: 1 }, condition: { v: { attributeExists: true } } })`); + }); + + test("versioned, with a custom partition key and index fields", async () => { + await checkValid(`ddb.put({ key: { id: "1" }, item: { v: 1 }, _version: 3, customPartitionKey: "cpk", populateIndexFields: true })`); + }); }); - test("remove", async () => { - await checkValid(`ddb.remove({ key: { id: "test" } })`); + describe("remove", () => { + test("by key", async () => { + await checkValid(`ddb.remove({ key: { id: "test" } })`); + }); + + test("versioned and guarded by a condition", async () => { + await checkValid(`ddb.remove({ key: { id: "1" }, _version: 2, condition: { a: { eq: 1 } } })`); + }); + }); + + describe("scan", () => { + test("with a limit and a page token", async () => { + await checkValid(`ddb.scan({ limit: 10, nextToken: "abc"})`); + }); + + test("with a filter and a projection", async () => { + await checkValid(`ddb.scan({ limit: 2, filter: { a: { eq: 1 } }, projection: ["a"] })`); + }); + + test("of one segment of an index", async () => { + await checkValid(`ddb.scan({ totalSegments: 4, segment: 1, index: "gsi", consistentRead: true })`); + }); + + test("without any arguments", async () => { + await checkValid(`ddb.scan({})`); + }); + }); + + describe("query", () => { + test("on a key", async () => { + await checkValid(`ddb.query({ query: { id: { eq: "1" } } })`); + }); + + test("on a partition and a sort key", async () => { + await checkValid(`ddb.query({ query: { a: { eq: "x" }, b: { beginsWith: "y" } } })`); + }); + + test("on a range of sort keys", async () => { + await checkValid(`ddb.query({ query: { sk: { between: [1, 9] } } })`); + }); + + test("with every argument", async () => { + await checkValid(`ddb.query({ query: { pk: { eq: "a" } }, index: "gsi1", limit: 5, nextToken: "t", consistentRead: true, scanIndexForward: false, select: "ALL_ATTRIBUTES", filter: { done: { eq: true } }, projection: ["id", "nested.field"] })`); + }); + }); + + describe("sync", () => { + test("since the last one", async () => { + await checkValid(`ddb.sync({ limit: 10, nextToken: "abc", lastSync: 1 })`); + }); + + test("of a delta index with a filter", async () => { + await checkValid(`ddb.sync({ basePartitionKey: "bpk", deltaIndexName: "delta", limit: 5, lastSync: 2, filter: { a: { eq: 1 } } })`); + }); + }); + + describe("batch", () => { + test("get", async () => { + await checkValid(`ddb.batchGet({ tables: { post: { keys: [{ id: "1" }, { id: "2" }], consistentRead: false, projection: ["id", "name"] } } })`); + }); + + test("get without options", async () => { + await checkValid(`ddb.batchGet({ tables: { post: { keys: [{ id: "1" }] } } })`); + }); + + test("put", async () => { + await checkValid(`ddb.batchPut({ tables: { post: [{ id: "one", value: "x" }] } })`); + }); + + test("delete", async () => { + await checkValid(`ddb.batchDelete({ tables: { post: [{ id: "one" }] } })`); + }); }); - test("scan", async () => { - await checkValid(`ddb.scan({ limit: 10, nextToken: "abc"})`); + describe("transact", () => { + test("get", async () => { + await checkValid(`ddb.transactGet({ items: [{ table: "post", key: { id: "1" }, projection: ["name"] }] })`); + }); + + test("get without a projection", async () => { + await checkValid(`ddb.transactGet({ items: [{ table: "post", key: { id: "1" } }] })`); + }); + + test("write putting and deleting", async () => { + await checkValid(`ddb.transactWrite({ items: [{ putItem: { table: "post", key: { id: "1" }, item: { a: 1 } } }, { deleteItem: { table: "post", key: { id: "2" }, condition: { v: { eq: 1 } } } }] })`); + }); + + test("write updating and checking a condition", async () => { + await checkValid(`ddb.transactWrite({ items: [{ updateItem: { table: "t", key: { id: "1" }, update: { a: ddb.operations.increment(1) } } }, { conditionCheck: { table: "t", key: { id: "2" }, condition: { v: { eq: 1 } } } }] })`); + }); }); - // Not implemented on AWS - // Error: code.js(5,14): error TS2339: Property 'sync' does not exist on type 'typeof import("/var/task/node_modules/@amzn/awsapp-sync-jsvtltranspiler/bundled/@aws-appsync/utils/lib/dynamo-db-helpers")'. - test.skip("sync", async () => { - await checkValid(`ddb.sync({ limit: 10, nextToken: "abc", lastSync: 1 })`); + describe("set helpers", () => { + test("toStringSet", async () => { + await checkValid(`ddb.toStringSet(["a", "b"])`); + }); + + // recorded unstringified, against what the type definitions describe + test("toNumberSet", async () => { + await checkValid(`ddb.toNumberSet([1, 2, 3])`); + }); + + test("toBinarySet", async () => { + await checkValid(`ddb.toBinarySet(["SGVsbG8="])`); + }); }); describe("update", () => { @@ -152,6 +273,10 @@ describe("DynamoDB module functions", () => { await checkValid(`ddb.update({ key: { id: "test" }, update: { age: ddb.operations.increment(10) } })`); }); + test("increment by one when given no step", async () => { + await checkValid(`ddb.update({ key: { id: "1" }, update: { a: ddb.operations.increment() } })`); + }); + test("prepend", async () => { await checkValid(`ddb.update({ key: { id: "test" }, update: { values: ddb.operations.prepend([1, 2, 3]) } })`); }); @@ -159,6 +284,44 @@ describe("DynamoDB module functions", () => { test("replace", async () => { await checkValid(`ddb.update({ key: { id: "test" }, update: { values: ddb.operations.replace({ a: 10 }) }})`); }); + + test("remove", async () => { + await checkValid(`ddb.update({ key: { id: "1" }, update: { addr: ddb.operations.remove() } })`); + }); + + test("a bare value sets the attribute", async () => { + await checkValid(`ddb.update({ key: { id: "1" }, update: { isVerified: true } })`); + }); + + test("two attributes at once", async () => { + await checkValid(`ddb.update({ key: { id: "1" }, update: { a: ddb.operations.increment(1), b: ddb.operations.replace("x") } })`); + }); + + test("two removals share one clause", async () => { + await checkValid(`ddb.update({ key: { id: "1" }, update: { a: ddb.operations.remove(), b: ddb.operations.remove() } })`); + }); + + // the clauses are grouped by keyword, so the aliases are numbered in the order they come out + // rather than the order they were written + test("setting and removing together", async () => { + await checkValid(`ddb.update({ key: { id: "1" }, update: { a: ddb.operations.increment(1), b: ddb.operations.remove() } })`); + }); + + test("removing written before setting", async () => { + await checkValid(`ddb.update({ key: { id: "1" }, update: { b: ddb.operations.remove(), a: ddb.operations.increment(1) } })`); + }); + + test("an item of a list", async () => { + await checkValid(`ddb.update({ key: { id: "1" }, update: { friends: [ddb.operations.updateListItem("x", 1)] } })`); + }); + + test("two items of one list share an alias", async () => { + await checkValid(`ddb.update({ key: { id: "1" }, update: { f: [ddb.operations.updateListItem("x", 1), ddb.operations.updateListItem("y", 2)] } })`); + }); + + test("versioned and guarded by a condition", async () => { + await checkValid(`ddb.update({ key: { id: "1" }, update: { a: ddb.operations.increment(1) }, condition: { b: { eq: 2 } }, _version: 5 })`); + }); }); }) @@ -221,7 +384,13 @@ describe("DynamoDB operations", () => { await checkValid(`ddb.operations.replace({ a: 10 })`); }); - // not implemented currently + test("remove", async () => { + await checkValid(`ddb.operations.remove()`); + }); + + // AWS answers an opaque value for this marker alone: it has no enumerable keys and serialises to + // nothing, so the returned marker cannot be compared. What it builds is covered by the `update` + // tests, which do match AWS. test.skip("updateListItem", async () => { await checkValid(`ddb.operations.updateListItem('foo', 1)`); }); diff --git a/__tests__/transform-subscription.test.js b/__tests__/transform-subscription.test.js new file mode 100644 index 0000000..95401a8 --- /dev/null +++ b/__tests__/transform-subscription.test.js @@ -0,0 +1,143 @@ +// `util.transform.toSubscriptionFilter` turns a filter into the form AppSync delivers subscriptions +// against: a list of groups, each an AND of individual filters, the groups themselves OR-ed. Every +// operator of a field, every member of an `or` and every rule is an alternative, so the groups +// multiply out. Unlike the DynamoDB transforms it answers an object rather than a JSON string. +import { checkValid } from "./helpers"; + +const subscriptionFilter = (...args) => + checkValid(`util.transform.toSubscriptionFilter(${args.join(", ")})`); + +describe("subscription filters", () => { + describe("a single group", () => { + test("one operator on one field", async () => { + await subscriptionFilter(`{ a: { eq: 1 } }`); + }); + + test("two fields are combined", async () => { + await subscriptionFilter(`{ a: { eq: 1 }, b: { eq: 2 } }`); + }); + + test("an and combines its members", async () => { + await subscriptionFilter(`{ and: [{ a: { eq: 1 } }, { b: { eq: 2 } }] }`); + }); + + test("a field beside an and", async () => { + await subscriptionFilter(`{ z: { eq: 0 }, and: [{ a: { eq: 1 } }] }`); + }); + + test("a field path is kept as written", async () => { + await subscriptionFilter(`{ "loc.addr.country": { eq: "USA" } }`); + }); + }); + + describe("alternatives multiply the groups out", () => { + test("two operators on one field", async () => { + await subscriptionFilter(`{ a: { eq: 1, ne: 2 } }`); + }); + + test("an or splits into a group per member", async () => { + await subscriptionFilter(`{ or: [{ a: { eq: 1 } }, { b: { eq: 2 } }] }`); + }); + + test("an or nested in an and", async () => { + await subscriptionFilter(`{ and: [{ a: { eq: 1 } }, { or: [{ b: { eq: 2 } }, { c: { eq: 3 } }] }] }`); + }); + + test("operators and groups together", async () => { + await subscriptionFilter( + `{ percentageUp: { lte: 50, gte: 20 }, and: [{ title: { ne: "Book1" } }, { downvotes: { gt: 2000 } }], or: [{ author: { eq: "Admin" } }, { isPublished: { eq: false } }] }`, + ); + }); + }); + + describe("operators are carried through as written", () => { + test("the comparisons", async () => { + await subscriptionFilter(`{ a: { le: 1 }, b: { lt: 2 }, c: { ge: 3 }, d: { gt: 4 } }`); + }); + + test("membership", async () => { + await subscriptionFilter(`{ a: { in: [1, 2] }, b: { notIn: ["x"] } }`); + }); + + test("between", async () => { + await subscriptionFilter(`{ a: { between: [1, 9] } }`); + }); + + test("the string operators", async () => { + await subscriptionFilter(`{ a: { contains: "x" }, b: { notContains: "y" }, c: { beginsWith: "z" } }`); + }); + + test("containsAny", async () => { + await subscriptionFilter(`{ a: { containsAny: ["x", "y"] } }`); + }); + + test("a boolean value", async () => { + await subscriptionFilter(`{ a: { eq: false } }`); + }); + + // nothing validates the operator, so an unknown one is passed straight through + test("an unknown operator", async () => { + await subscriptionFilter(`{ a: { bogus: 1 } }`); + }); + + // `not` is not a negation here, unlike the DynamoDB filter object: it is read as a field name + test("not is treated as a field", async () => { + await subscriptionFilter(`{ not: { a: { eq: 1 } } }`); + }); + }); + + describe("ignored fields", () => { + test("drop a field", async () => { + await subscriptionFilter(`{ a: { eq: 1 }, b: { eq: 2 } }`, `["a"]`); + }); + + test("drop a field nested in an and", async () => { + await subscriptionFilter(`{ a: { eq: 1 }, and: [{ b: { eq: 2 } }] }`, `["b"]`); + }); + }); + + describe("rules", () => { + // at least one rule has to hold, so each is an alternative added to every group + test("are added as alternatives", async () => { + await subscriptionFilter(`{ a: { eq: 1 } }`, `[]`, `{ r1: { gte: 250 }, r2: { eq: "p" } }`); + }); + + test("on their own", async () => { + await subscriptionFilter(`{}`, `[]`, `{ r1: { eq: 1 } }`); + }); + }); + + describe("filters that produce no group", () => { + test("an empty filter", async () => { + await subscriptionFilter(`{}`); + }); + + test("a field with no operator", async () => { + await subscriptionFilter(`{ a: {} }`); + }); + + test("an empty and", async () => { + await subscriptionFilter(`{ and: [] }`); + }); + }); + + describe("filters that cannot be read", () => { + test("null", async () => { + await subscriptionFilter(`null`); + }); + + test("no filter at all", async () => { + await subscriptionFilter(``); + }); + + test("an array", async () => { + await subscriptionFilter(`[]`); + }); + + // the documented client-side pattern passes `ctx.args.filter` straight in, but a JSON string + // is not read as a filter + test("a JSON string", async () => { + await subscriptionFilter(`'{"a":{"eq":1}}'`); + }); + }); +}); diff --git a/dynamodb/index.js b/dynamodb/index.js index e81dd35..b753320 100644 --- a/dynamodb/index.js +++ b/dynamodb/index.js @@ -1,51 +1,63 @@ -import { util } from '../index.js'; - -// TODO: consistentRead -// TODO: projection -export const get = (input) => { - let out = { operation: "GetItem" }; - out.key = util.dynamodb.toMapValues(input.key); - return out; -}; - -// TODO: condition -// TODO: customPartitionKey -// TODO: populateIndexFields -// TODO: _version -export const put = (payload) => { - let out = { operation: "PutItem" }; - out.key = {}; - for (const [k, v] of Object.entries(payload.key)) { - out.key[k] = util.dynamodb.toDynamoDB(v); - } - out.attributeValues = {}; - for (const [k, v] of Object.entries(payload.item)) { - out.attributeValues[k] = util.dynamodb.toDynamoDB(v); - } - - return out; -}; +import { util, buildDynamoDBExpression } from '../index.js'; -export const remove = (payload) => { - let out = { operation: "DeleteItem" }; - out.key = util.dynamodb.toMapValues(payload.key); - return out; -}; +const toKey = (key) => util.dynamodb.toMapValues(key); -export const scan = (payload) => { - let out = payload; - out.operation = "Scan"; - return out; -}; +// An optional argument that was not given is left out of the request entirely, and a null is the +// same as absent for every one of them. +function withOptional(request, optional) { + for (const [field, value] of Object.entries(optional)) { + if (value !== undefined && value !== null) { + request[field] = value; + } + } + return request; +} -// Not implemented on AWS -// Error: code.js(5,14): error TS2339: Property 'sync' does not exist on type 'typeof import("/var/task/node_modules/@amzn/awsapp-sync-jsvtltranspiler/bundled/@aws-appsync/utils/lib/dynamo-db-helpers")'. -/* -export const sync = (payload) => { -}; -*/ +// A `condition`, `filter` or key condition travels as an expression object rather than as the JSON +// string `util.transform` produces. Unlike the update expression below, an expression that binds no +// value carries no `expressionValues` at all. +// +// The clauses come out in the order the fields were written. AWS orders them by its own object +// iteration instead, which for some sets of field names is neither the written order nor sorted, so +// a condition on several fields can be joined in a different order there. `AND` commutes, so the +// two mean the same thing. +function toExpression(filter) { + if (filter === undefined || filter === null) { + return undefined; + } + const built = buildDynamoDBExpression(filter); + if (built === null) { + return undefined; + } + const expression = { expression: built.expression, expressionNames: built.expressionNames }; + if (Object.keys(built.expressionValues).length) { + expression.expressionValues = built.expressionValues; + } + return expression; +} -// Transformations +// A projection aliases every segment of every path, so `nested.field` becomes `#expName_2.#expName_3` +// and a segment used twice keeps the one alias. +function toProjection(projection) { + if (!Array.isArray(projection) || projection.length === 0) { + return undefined; + } + const names = new Map(); + const alias = (segment) => { + if (!names.has(segment)) { + names.set(segment, `#expName_${names.size + 1}`); + } + return names.get(segment); + }; + const expression = projection + .map((path) => String(path).split(".").map(alias).join(".")) + .join(", "); + const expressionNames = {}; + for (const [segment, name] of names) { + expressionNames[name] = segment; + } + return { expression, expressionNames }; +} // Operations @@ -54,80 +66,10 @@ const OPERATION_APPEND = "OPERATION_APPEND"; const OPERATION_DECREMENT = "OPERATION_DECREMENT"; const OPERATION_INCREMENT = "OPERATION_INCREMENT"; const OPERATION_PREPEND = "OPERATION_PREPEND"; +const OPERATION_REMOVE = "OPERATION_REMOVE"; const OPERATION_REPLACE = "OPERATION_REPLACE"; const OPERATION_UPDATE_LIST_ITEM = "OPERATION_UPDATE_LIST_ITEM"; -export const update = (payload) => { - let out = { operation: "UpdateItem" }; - out.key = util.dynamodb.toMapValues(payload.key); - out.update = {}; - - let expressions = []; - let expressionNames = {}; - let expressionValues = {}; - let idx = 1; - for (const [k, op] of Object.entries(payload.update)) { - - let expression; - let expressionName; - let expressionValue; - let value; - - switch (op.type) { - case OPERATION_ADD: - expressionName = `#expName_${idx}`; - expressionValue = `:expValue_${idx}`; - expression = `SET ${expressionName} = ${expressionValue}`; - value = op.value; - break; - case OPERATION_APPEND: - expressionName = `#expName_${idx}`; - expressionValue = `:expValue_${idx}`; - expression = `SET ${expressionName} = list_append(${expressionName}, ${expressionValue})`; - value = op.items; - break; - case OPERATION_DECREMENT: - expressionName = `#expName_${idx}`; - expressionValue = `:expValue_${idx}`; - expression = `SET ${expressionName} = ${expressionName} - ${expressionValue}`; - value = op.by; - break; - case OPERATION_INCREMENT: - expressionName = `#expName_${idx}`; - expressionValue = `:expValue_${idx}`; - expression = `SET ${expressionName} = ${expressionName} + ${expressionValue}`; - value = op.by; - break; - case OPERATION_PREPEND: - expressionName = `#expName_${idx}`; - expressionValue = `:expValue_${idx}`; - expression = `SET ${expressionName} = list_append(${expressionValue}, ${expressionName})`; - value = op.items; - break; - case OPERATION_REPLACE: - expressionName = `#expName_${idx}`; - expressionValue = `:expValue_${idx}`; - expression = `SET ${expressionName} = ${expressionValue}`; - value = op.value; - break; - default: - throw new Error(`update not implemented for ${op.type}`); - } - - expressions.push(expression); - expressionNames[expressionName] = k; - expressionValues[expressionValue] = util.dynamodb.toDynamoDB(value); - - idx++; - } - - out.update.expression = expressions.join(","); - out.update.expressionNames = expressionNames; - out.update.expressionValues = expressionValues; - - return out; -}; - export const operations = { add: (value) => { return { type: OPERATION_ADD, value: value }; @@ -144,10 +86,275 @@ export const operations = { prepend: (value) => { return { type: OPERATION_PREPEND, items: value }; }, + remove: () => { + return { type: OPERATION_REMOVE }; + }, replace: (value) => { return { type: OPERATION_REPLACE, value: value }; }, - // updateListItem: (value) => { - // return {type: OPERATION_UPDATE_LIST_ITEM, value: value}; - // }, + updateListItem: (value, index) => { + return { type: OPERATION_UPDATE_LIST_ITEM, value: value, index: index }; + }, }; + +const isOperation = (value) => + value !== null && + typeof value === "object" && + !Array.isArray(value) && + typeof value.type === "string" && + value.type.startsWith("OPERATION_"); + +const isListItemOperations = (value) => + Array.isArray(value) && + value.length > 0 && + value.every((item) => isOperation(item) && item.type === OPERATION_UPDATE_LIST_ITEM); + +// `increment` and `decrement` step by one when they are given nothing to step by +const stepBy = (by) => (by === undefined || by === null ? 1 : by); + +// An update expression carries one `SET` clause list followed by one `REMOVE` clause list, so the +// attribute aliases are numbered in the order the clauses come out rather than the order they were +// written. An attribute keeps a single alias however many clauses mention it. +function toUpdateExpression(update) { + const sets = []; + const removes = []; + + for (const [field, operation] of Object.entries(update)) { + if (isListItemOperations(operation)) { + for (const item of operation) { + sets.push({ field, render: (name, value) => `${name}[${item.index}] = ${value(item.value)}` }); + } + continue; + } + if (!isOperation(operation)) { + // a bare value updates the attribute to it, the same as `replace` + sets.push({ field, render: (name, value) => `${name} = ${value(operation)}` }); + continue; + } + switch (operation.type) { + case OPERATION_ADD: + case OPERATION_REPLACE: + sets.push({ field, render: (name, value) => `${name} = ${value(operation.value)}` }); + break; + case OPERATION_APPEND: + sets.push({ field, render: (name, value) => `${name} = list_append(${name}, ${value(operation.items)})` }); + break; + case OPERATION_PREPEND: + sets.push({ field, render: (name, value) => `${name} = list_append(${value(operation.items)}, ${name})` }); + break; + case OPERATION_INCREMENT: + sets.push({ field, render: (name, value) => `${name} = ${name} + ${value(stepBy(operation.by))}` }); + break; + case OPERATION_DECREMENT: + sets.push({ field, render: (name, value) => `${name} = ${name} - ${value(stepBy(operation.by))}` }); + break; + case OPERATION_REMOVE: + removes.push({ field }); + break; + case OPERATION_UPDATE_LIST_ITEM: + sets.push({ + field, + render: (name, value) => `${name}[${operation.index}] = ${value(operation.value)}`, + }); + break; + default: + throw new Error(`update not implemented for ${operation.type}`); + } + } + + const names = new Map(); + const nameOf = (field) => { + if (!names.has(field)) { + names.set(field, `#expName_${names.size + 1}`); + } + return names.get(field); + }; + const expressionValues = {}; + const valueOf = (value) => { + const placeholder = `:expValue_${Object.keys(expressionValues).length + 1}`; + expressionValues[placeholder] = util.dynamodb.toDynamoDB(value); + return placeholder; + }; + + const clauses = []; + if (sets.length) { + clauses.push(`SET ${sets.map(({ field, render }) => render(nameOf(field), valueOf)).join(", ")}`); + } + if (removes.length) { + clauses.push(`REMOVE ${removes.map(({ field }) => nameOf(field)).join(", ")}`); + } + + const expressionNames = {}; + for (const [field, name] of names) { + expressionNames[name] = field; + } + return { expression: clauses.join(" "), expressionNames, expressionValues }; +} + +// Requests + +export const get = (payload) => { + return withOptional( + { operation: "GetItem", key: toKey(payload.key) }, + { consistentRead: payload.consistentRead, projection: toProjection(payload.projection) }, + ); +}; + +export const put = (payload) => { + return withOptional( + { + operation: "PutItem", + key: toKey(payload.key), + attributeValues: util.dynamodb.toMapValues(payload.item), + }, + { + condition: toExpression(payload.condition), + _version: payload._version, + customPartitionKey: payload.customPartitionKey, + populateIndexFields: payload.populateIndexFields, + }, + ); +}; + +export const remove = (payload) => { + return withOptional( + { operation: "DeleteItem", key: toKey(payload.key) }, + { condition: toExpression(payload.condition), _version: payload._version }, + ); +}; + +export const update = (payload) => { + return withOptional( + { + operation: "UpdateItem", + key: toKey(payload.key), + update: toUpdateExpression(payload.update), + }, + { condition: toExpression(payload.condition), _version: payload._version }, + ); +}; + +export const scan = (payload = {}) => { + return withOptional( + { operation: "Scan" }, + { + index: payload.index, + limit: payload.limit, + nextToken: payload.nextToken, + consistentRead: payload.consistentRead, + segment: payload.segment, + totalSegments: payload.totalSegments, + filter: toExpression(payload.filter), + projection: toProjection(payload.projection), + }, + ); +}; + +export const query = (payload) => { + return withOptional( + { operation: "Query", query: toExpression(payload.query) }, + { + index: payload.index, + limit: payload.limit, + nextToken: payload.nextToken, + consistentRead: payload.consistentRead, + scanIndexForward: payload.scanIndexForward, + select: payload.select, + filter: toExpression(payload.filter), + projection: toProjection(payload.projection), + }, + ); +}; + +export const sync = (payload = {}) => { + return withOptional( + { operation: "Sync" }, + { + basePartitionKey: payload.basePartitionKey, + deltaIndexName: payload.deltaIndexName, + limit: payload.limit, + nextToken: payload.nextToken, + lastSync: payload.lastSync, + filter: toExpression(payload.filter), + }, + ); +}; + +export const batchGet = (payload) => { + const tables = {}; + for (const [table, request] of Object.entries(payload.tables)) { + tables[table] = withOptional( + { keys: request.keys.map(toKey) }, + { consistentRead: request.consistentRead, projection: toProjection(request.projection) }, + ); + } + return { operation: "BatchGetItem", tables }; +}; + +export const batchPut = (payload) => { + const tables = {}; + for (const [table, items] of Object.entries(payload.tables)) { + tables[table] = items.map((item) => util.dynamodb.toMapValues(item)); + } + return { operation: "BatchPutItem", tables }; +}; + +export const batchDelete = (payload) => { + const tables = {}; + for (const [table, keys] of Object.entries(payload.tables)) { + tables[table] = keys.map(toKey); + } + return { operation: "BatchDeleteItem", tables }; +}; + +export const transactGet = (payload) => { + const transactItems = payload.items.map((item) => + withOptional({ table: item.table, key: toKey(item.key) }, { projection: toProjection(item.projection) }), + ); + return { operation: "TransactGetItems", transactItems }; +}; + +export const transactWrite = (payload) => { + const transactItems = payload.items.map((item) => { + if (item.putItem) { + const { table, key, item: attributes, condition } = item.putItem; + return withOptional( + { + table, + operation: "PutItem", + key: toKey(key), + attributeValues: util.dynamodb.toMapValues(attributes), + }, + { condition: toExpression(condition) }, + ); + } + if (item.updateItem) { + const { table, key, update: updateObject, condition } = item.updateItem; + return withOptional( + { table, operation: "UpdateItem", key: toKey(key), update: toUpdateExpression(updateObject) }, + { condition: toExpression(condition) }, + ); + } + if (item.deleteItem) { + const { table, key, condition } = item.deleteItem; + return withOptional( + { table, operation: "DeleteItem", key: toKey(key) }, + { condition: toExpression(condition) }, + ); + } + const { table, key, condition } = item.conditionCheck; + return withOptional( + { table, operation: "ConditionCheck", key: toKey(key) }, + { condition: toExpression(condition) }, + ); + }); + return { operation: "TransactWriteItems", transactItems }; +}; + +// Set helpers. The `util.dynamodb` versions of these accept a null and answer one; these do not. + +export const toStringSet = (list) => ({ SS: list }); + +export const toNumberSet = (numbers) => ({ NS: numbers }); + +export const toBinarySet = (values) => ({ BS: values }); diff --git a/index.js b/index.js index 849f5f1..1b857fb 100644 --- a/index.js +++ b/index.js @@ -114,7 +114,25 @@ export const dynamodbUtils = { }, fromS3ObjectJson: function(value) { - throw new Error("not implemented"); + // takes the JSON string an S3 link is stored as, not the `{S: ...}` attribute wrapping it + if (typeof value !== "string") { + return null; + } + let parsed; + try { + parsed = JSON.parse(value); + } catch (error) { + return null; + } + const s3 = parsed?.s3; + if (!s3) { + return null; + } + const out = { bucket: s3.bucket, region: s3.region, key: s3.key }; + if (s3.version !== undefined) { + out.version = s3.version; + } + return out; }, } @@ -227,6 +245,9 @@ export const util = { toDynamoDBConditionExpression: function(condition) { return transformToExpression(condition); }, + toSubscriptionFilter: function(filter, ignoredFields, rules) { + return toSubscriptionFilter(filter, ignoredFields, rules); + }, }, dynamodb: dynamodbUtils, rds: { toJsonObject }, @@ -276,21 +297,28 @@ const isFilterObject = (value) => value !== null && typeof value === "object" && const hasOperator = (operators, operator) => Object.hasOwn(operators, operator); -function transformToExpression(filter) { - let node; +// The `{expression, expressionNames, expressionValues}` object a DynamoDB request carries. +// `util.transform` serialises it, while the `dynamodb` module embeds it as an object, so both go +// through the one builder. `null` for a filter that cannot be rendered, matching `util.transform`. +export function buildDynamoDBExpression(filter) { try { - node = buildFilter(filter, [], false); + const node = buildFilter(filter, [], false); + return { + expression: node.expression, + expressionNames: node.expressionNames, + expressionValues: node.expressionValues, + }; } catch (error) { if (error instanceof InvalidFilter) { return null; } throw error; } - return JSON.stringify({ - expression: node.expression, - expressionNames: node.expressionNames, - expressionValues: node.expressionValues, - }); +} + +function transformToExpression(filter) { + const expression = buildDynamoDBExpression(filter); + return expression === null ? null : JSON.stringify(expression); } // `wrap` parenthesises a group that holds more than one member. The filter as a whole is never @@ -417,3 +445,74 @@ function buildOperator(target, operator, operand, value) { throw new InvalidFilter(); } } + +// `util.transform.toSubscriptionFilter` expands a filter into the disjunctive normal form AppSync +// delivers subscriptions against: a list of groups, each an AND of individual filters, the groups +// themselves OR-ed. Recorded from AWS, which validates nothing - an unknown operator is passed +// straight through, and `not` is treated as an ordinary field name rather than a negation. + +// every operator of a field, every member of an `or`, and every rule is an alternative, so the +// groups multiply out +const crossFilters = (groups, alternatives) => + groups.flatMap((group) => alternatives.map((alternative) => [...group, ...alternative])); + +function subscriptionFilterAlternatives(field, operators) { + if (!isFilterObject(operators)) { + throw new InvalidFilter(); + } + return Object.entries(operators).map(([operator, value]) => [{ fieldName: field, operator, value }]); +} + +function expandSubscriptionFilter(filter, ignoredFields) { + if (!isFilterObject(filter)) { + throw new InvalidFilter(); + } + let groups = [[]]; + for (const [key, value] of Object.entries(filter)) { + if (key === "and") { + if (!Array.isArray(value)) { + throw new InvalidFilter(); + } + for (const member of value) { + groups = crossFilters(groups, expandSubscriptionFilter(member, ignoredFields)); + } + continue; + } + if (key === "or") { + if (!Array.isArray(value)) { + throw new InvalidFilter(); + } + groups = crossFilters(groups, value.flatMap((member) => expandSubscriptionFilter(member, ignoredFields))); + continue; + } + if (ignoredFields.includes(key)) { + continue; + } + groups = crossFilters(groups, subscriptionFilterAlternatives(key, value)); + } + return groups; +} + +function toSubscriptionFilter(filter, ignoredFields, rules) { + const ignored = Array.isArray(ignoredFields) ? ignoredFields : []; + let groups; + try { + groups = expandSubscriptionFilter(filter, ignored); + if (isFilterObject(rules)) { + // a rule is satisfied when any one of them holds, so they multiply onto every group + const alternatives = Object.entries(rules).flatMap(([field, operators]) => + subscriptionFilterAlternatives(field, operators), + ); + if (alternatives.length) { + groups = crossFilters(groups, alternatives); + } + } + } catch (error) { + if (error instanceof InvalidFilter) { + return null; + } + throw error; + } + // a group that collected no filter at all is dropped, so an empty filter yields an empty list + return { filterGroup: groups.filter((filters) => filters.length).map((filters) => ({ filters })) }; +} diff --git a/package.json b/package.json index b243d5a..b05d558 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@localstack/appsync-utils", - "version": "0.1.6", + "version": "0.1.7", "description": "Implementation of the AppSync utils helpers", "type": "module", "main": "index.js",