From 46b82acc3a3ad0639b534f977ca846d3bdbea1bf Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Wed, 18 Oct 2023 16:26:28 +0100 Subject: [PATCH] Implement dynamodb condition expression --- __tests__/__snapshots__/index.test.js.snap | 2 + __tests__/helpers.js | 5 +- __tests__/index.test.js | 28 ++++ dynamodb-utils.js | 112 ++++++++++++++ index.js | 123 ++------------- transform/dynamodb-filter.js | 167 +++++++++++++++++++++ 6 files changed, 325 insertions(+), 112 deletions(-) create mode 100644 dynamodb-utils.js create mode 100644 transform/dynamodb-filter.js diff --git a/__tests__/__snapshots__/index.test.js.snap b/__tests__/__snapshots__/index.test.js.snap index 215cad4..903e6c5 100644 --- a/__tests__/__snapshots__/index.test.js.snap +++ b/__tests__/__snapshots__/index.test.js.snap @@ -258,6 +258,8 @@ exports[`DynamoDB operations replace 1`] = ` exports[`DynamoDB operations updateListItem 1`] = `undefined`; +exports[`Transformations toDynamoDBConditionExpression 1`] = `"{"expression":"(attribute_exists(#id)) AND (#version = :version_eq)","expressionNames":{"#id":"id","#version":"version"},"expressionValues":{":version_eq":{"N":10}}}"`; + exports[`Transformations toDynamoDBFilterMap 1`] = `"{"expression":"(contains(#title,:title_contains))","expressionNames":{"#title":"title"},"expressionValues":{":title_contains":{"S":"Hello World"}}}"`; exports[`dynamodb helpers s3 objects four parameter function 1`] = ` diff --git a/__tests__/helpers.js b/__tests__/helpers.js index fb07970..f70e0e4 100644 --- a/__tests__/helpers.js +++ b/__tests__/helpers.js @@ -46,12 +46,15 @@ const runOnAWS = async (s, context) => { } // If TEST_TARGET is AWS_CLOUD then run the check against AWS. Otherwise, run locally. -export const checkValid = async (s, context) => { +export const checkValid = async (s, context, postProcess) => { let result; if (process.env.TEST_TARGET === "AWS_CLOUD") { result = await runOnAWS(s, context); } else { result = eval(s); } + if (postProcess) { + result = postProcess(result); + } expect(result).toMatchSnapshot(); } diff --git a/__tests__/index.test.js b/__tests__/index.test.js index 6592c3e..9c188a9 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -137,6 +137,34 @@ describe("Transformations", () => { test("toDynamoDBFilterMap", async () => { await checkValid(`util.transform.toDynamoDBFilterExpression({ "title":{ "contains":"Hello World" } })`); }); + + test("toDynamoDBConditionExpression", async () => { + // attribute keys are not guaranteed to be ordered + const postProcess = (result) => { + + const sortObjectByKeys = (obj) => { + return Object.keys(obj).sort().reduce( + (res, key) => { + res[key] = obj[key]; + return res; + }, + {} + ); + }; + + const { expression, expressionNames, expressionValues } = JSON.parse(result); + const transformed = { + expression, + expressionNames: sortObjectByKeys(expressionNames), + expressionValues: sortObjectByKeys(expressionValues), + }; + return JSON.stringify(transformed); + }; + await checkValid(`util.transform.toDynamoDBConditionExpression({ + id: { attributeExists: true }, + version: { eq: 10 }, + })`, {}, postProcess); + }); }); describe("DynamoDB operations", () => { diff --git a/dynamodb-utils.js b/dynamodb-utils.js new file mode 100644 index 0000000..a0f2497 --- /dev/null +++ b/dynamodb-utils.js @@ -0,0 +1,112 @@ +export const dynamodbUtils = { + toDynamoDB: function(value) { + if (typeof (value) === "number") { + return this.toNumber(value); + } else if (typeof (value) === "string") { + return this.toString(value); + } else if (typeof (value) === "boolean") { + return this.toBoolean(value); + } else if (typeof (value) === "object") { + if (value.length !== undefined) { + return this.toList(value); + } else { + return this.toMap(value); + } + } else { + throw new Error(`Not implemented for ${value}`); + } + }, + + toString: function(value) { + if (value === null) { return null; }; + + return { S: value }; + }, + + toStringSet: function(value) { + if (value === null) { return null; }; + + return { SS: value }; + }, + + toNumber: function(value) { + if (value === null) { return null; }; + + return { N: value }; + }, + + toNumberSet: function(value) { + if (value === null) { return null; }; + + return { NS: value }; + }, + + toBinary: function(value) { + if (value === null) { return null; }; + + return { B: value }; + }, + + toBinarySet: function(value) { + if (value === null) { return null; }; + + return { BS: value }; + }, + + toBoolean: function(value) { + if (value === null) { return null; }; + + return { BOOL: value }; + }, + + toNull: function() { + return { NULL: null }; + }, + + toList: function(values) { + let out = []; + for (const value of values) { + out.push(this.toDynamoDB(value)); + } + return { L: out } + }, + + toMap: function(mapping) { + return { M: this.toMapValues(mapping) }; + }, + + toMapValues: function(mapping) { + let out = {}; + for (const [k, v] of Object.entries(mapping)) { + out[k] = this.toDynamoDB(v); + } + return out; + }, + + toS3Object: function(key, bucket, region, version) { + let payload; + if (version === undefined) { + payload = { + s3: { + key, + bucket, + region, + } + }; + } else { + payload = { + s3: { + key, + bucket, + region, + version, + } + }; + }; + return this.toString(JSON.stringify(payload)); + }, + + fromS3ObjectJson: function(value) { + throw new Error("not implemented"); + }, +} diff --git a/index.js b/index.js index aa8839b..5c0b3f6 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,8 @@ import { v4 as uuidv4 } from 'uuid'; +import { generateFilterExpression } from "./transform/dynamodb-filter"; +import { dynamodbUtils } from './dynamodb-utils'; + const FILTER_CONTAINS = "contains"; export const util = { @@ -42,117 +45,15 @@ export const util = { return JSON.stringify({ expression, expressionNames, expressionValues }); }, - }, - dynamodb: { - toDynamoDB: function(value) { - if (typeof (value) === "number") { - return this.toNumber(value); - } else if (typeof (value) === "string") { - return this.toString(value); - } else if (typeof (value) === "boolean") { - return this.toBoolean(value); - } else if (typeof (value) === "object") { - if (value.length !== undefined) { - return this.toList(value); - } else { - return this.toMap(value); - } - } else { - throw new Error(`Not implemented for ${value}`); - } - }, - - toString: function(value) { - if (value === null) { return null; }; - - return { S: value }; - }, - - toStringSet: function(value) { - if (value === null) { return null; }; - - return { SS: value }; - }, - - toNumber: function(value) { - if (value === null) { return null; }; - - return { N: value }; - }, - - toNumberSet: function(value) { - if (value === null) { return null; }; - - return { NS: value }; - }, - - toBinary: function(value) { - if (value === null) { return null; }; - - return { B: value }; - }, - - toBinarySet: function(value) { - if (value === null) { return null; }; - - return { BS: value }; - }, - - toBoolean: function(value) { - if (value === null) { return null; }; - - return { BOOL: value }; - }, - - toNull: function() { - return { NULL: null }; - }, - - toList: function(values) { - let out = []; - for (const value of values) { - out.push(this.toDynamoDB(value)); - } - return { L: out } - }, - - toMap: function(mapping) { - return { M: this.toMapValues(mapping) }; - }, - - toMapValues: function(mapping) { - let out = {}; - for (const [k, v] of Object.entries(mapping)) { - out[k] = this.toDynamoDB(v); - } - return out; - }, - - toS3Object: function(key, bucket, region, version) { - let payload; - if (version === undefined) { - payload = { - s3: { - key, - bucket, - region, - } - }; - } else { - payload = { - s3: { - key, - bucket, - region, - version, - } - }; - }; - return this.toString(JSON.stringify(payload)); - }, - - fromS3ObjectJson: function(value) { - throw new Error("not implemented"); + 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, + }); }, }, + dynamodb: dynamodbUtils, }; diff --git a/transform/dynamodb-filter.js b/transform/dynamodb-filter.js new file mode 100644 index 0000000..488948c --- /dev/null +++ b/transform/dynamodb-filter.js @@ -0,0 +1,167 @@ +// transformed from TS to JS from https://github.com/aws-amplify/amplify-cli/blob/dev/packages/amplify-appsync-simulator/src/velocity/util/transform/dynamodb-filter.ts +// and fixed to return expressionValues +import { dynamodbUtils } from '../dynamodb-utils'; + +const OPERATOR_MAP = { + ne: '<>', + eq: '=', + lt: '<', + le: '<=', + gt: '>', + ge: '>=', + in: 'contains', +}; + +const FUNCTION_MAP = { + 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: {}, + }, + ); + + return expr; +} + +function merge(expr1, expr2, joinCondition = 'AND') { + if (!expr2.expressions.length) { + return expr1; + } + + const res = { + expressions: [...expr1.expressions, expr1.expressions.length ? joinCondition : '', ...expr2.expressions], + expressionNames: { ...expr1.expressionNames, ...expr2.expressionNames }, + expressionValues: { ...expr1.expressionValues, ...expr2.expressionValues }, + }; + return res; +} + +function createExpressionValueName(fieldName, op, prefix) { + return `:${[prefix, fieldName, op].filter((name) => name).join('_')}`; +} +function createExpressionName(fieldName) { + return { + [createExpressionFieldName(fieldName)]: fieldName, + }; +} + +function createExpressionFieldName(fieldName) { + return `#${fieldName}`; +} +function createExpressionValue(fieldName, op, value, prefix) { + const exprName = createExpressionValueName(fieldName, op, prefix); + const exprValue = dynamodbUtils.toDynamoDB(value); + return { + [`${exprName}`]: exprValue, + }; +} + +function scopeExpression(expr) { + const result = { ...expr }; + result.expressions = result.expressions.filter((e) => !!e); + if (result.expressions.length > 1) { + result.expressions = ['(' + result.expressions.join(' ') + ')']; + } + return result; +}