├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ ├── backup.yml │ └── build.yml ├── .gitignore ├── .node-version ├── .npmrc ├── .prettierrc ├── license ├── logo.png ├── package-lock.json ├── package.json ├── readme.md ├── sh ├── build.ts ├── coverage.ts └── dev.ts ├── src ├── bug-reports.test.ts ├── cjs │ └── index.cjs ├── document-client.test.ts ├── document-client.ts ├── dynoexpr.d.ts ├── expressions │ ├── condition.test.ts │ ├── condition.ts │ ├── filter.test.ts │ ├── filter.ts │ ├── helpers.test.ts │ ├── helpers.ts │ ├── key-condition.test.ts │ ├── key-condition.ts │ ├── projection.test.ts │ ├── projection.ts │ ├── update-ops.test.ts │ ├── update-ops.ts │ ├── update.test.ts │ └── update.ts ├── index.test.ts ├── index.ts ├── operations │ ├── batch.test.ts │ ├── batch.ts │ ├── helpers.ts │ ├── single.test.ts │ ├── single.ts │ ├── transact.test.ts │ └── transact.ts ├── utils.test.ts └── utils.ts ├── tsconfig.build.json ├── tsconfig.json └── vite.config.mts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | __data__ 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "airbnb-base", 4 | "airbnb-typescript/base", 5 | "prettier", 6 | "plugin:@typescript-eslint/recommended", 7 | "plugin:import/typescript" 8 | ], 9 | "parser": "@typescript-eslint/parser", 10 | "plugins": [ 11 | "@typescript-eslint" 12 | ], 13 | "parserOptions": { 14 | "ecmaVersion": 9, 15 | "project": "./tsconfig.json" 16 | }, 17 | "env": { 18 | "node": true 19 | }, 20 | "rules": { 21 | "@typescript-eslint/comma-dangle": "off", 22 | "import/extensions": "off", 23 | "import/no-unresolved": "off", 24 | "import/prefer-default-export": "off", 25 | "import/no-extraneous-dependencies": "off" 26 | }, 27 | "overrides": [ 28 | { 29 | "files": [ 30 | "**/*.ts" 31 | ], 32 | "rules": { 33 | "no-undef": "off" 34 | } 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/backup.yml: -------------------------------------------------------------------------------- 1 | name: backup 2 | 3 | on: [push, delete] 4 | 5 | jobs: 6 | backup: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@main 10 | with: 11 | fetch-depth: "0" 12 | - uses: ruicsh/backup-action@main 13 | with: 14 | bitbucket_app_user: ${{ secrets.BACKUP_APP_USER }} 15 | bitbucket_app_password: ${{ secrets.BACKUP_APP_PASSWORD }} 16 | target_repo: tuplo/dynoexpr 17 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | node-version: [16.x, 18.x, 20.x] 13 | steps: 14 | - uses: actions/checkout@main 15 | with: 16 | fetch-depth: "0" 17 | - uses: actions/setup-node@main 18 | with: 19 | node-version: 20 20 | - uses: actions/cache@main 21 | with: 22 | path: node_modules 23 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 24 | - run: | 25 | npm install --frozen-lockfile --legacy-peer-deps --no-audit 26 | npm run lint 27 | npm run test:ci 28 | 29 | test-coverage: 30 | needs: test 31 | name: test-coverage 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@main 35 | with: 36 | fetch-depth: "0" 37 | - uses: actions/setup-node@main 38 | with: 39 | node-version: 20 40 | - uses: actions/cache@main 41 | with: 42 | path: node_modules 43 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 44 | - run: npm install --frozen-lockfile --legacy-peer-deps --no-audit 45 | - uses: paambaati/codeclimate-action@v2.7.2 46 | env: 47 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 48 | with: 49 | coverageCommand: npm run coverage 50 | debug: true 51 | 52 | publish-to-npm: 53 | needs: test 54 | runs-on: ubuntu-latest 55 | steps: 56 | - uses: actions/checkout@main 57 | with: 58 | fetch-depth: "0" 59 | - uses: actions/setup-node@main 60 | with: 61 | node-version: 20 62 | registry-url: https://registry.npmjs.org/ 63 | - run: npm install --frozen-lockfile --legacy-peer-deps --no-audit 64 | - run: npm run build 65 | - name: Semantic Release 66 | uses: cycjimmy/semantic-release-action@main 67 | with: 68 | branch: main 69 | env: 70 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 71 | NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.nyc_output 2 | /coverage 3 | /node_modules 4 | /dist 5 | /cjs 6 | *.log 7 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 20.14.0 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save=true 2 | access=public 3 | save-exact=true 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "printWidth": 80, 4 | "useTabs": true 5 | } 6 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Tuplo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuplo/dynoexpr/0d0bf4c00c3ec74652005425b5d6692d942f5828/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tuplo/dynoexpr", 3 | "description": "Expression builder for AWS.DynamoDB.DocumentClient", 4 | "version": "0.0.0-development", 5 | "repository": "git@github.com:tuplo/dynoexpr.git", 6 | "author": "Rui Costa", 7 | "license": "MIT", 8 | "keywords": [ 9 | "aws", 10 | "amazon", 11 | "dynamodb", 12 | "database", 13 | "nosql", 14 | "documentclient" 15 | ], 16 | "files": [ 17 | "dist/index.cjs", 18 | "dist/index.mjs", 19 | "dist/index.d.ts", 20 | "dist/dynoexpr.d.ts" 21 | ], 22 | "engines": { 23 | "node": ">=14" 24 | }, 25 | "main": "./dist/index.cjs", 26 | "module": "./dist/index.mjs", 27 | "exports": { 28 | ".": [ 29 | { 30 | "import": { 31 | "types": "./dist/index.d.ts", 32 | "default": "./dist/index.mjs" 33 | }, 34 | "require": { 35 | "types": "./dist/index.d.ts", 36 | "default": "./dist/index.cjs" 37 | }, 38 | "default": "./dist/index.mjs" 39 | }, 40 | "./dist/index.mjs" 41 | ] 42 | }, 43 | "types": "dist/index.d.ts", 44 | "scripts": { 45 | "build": "tsx sh/build.ts", 46 | "coverage": "tsx sh/coverage.ts", 47 | "dev": "tsx sh/dev.ts", 48 | "format": "prettier --write src sh", 49 | "lint:ts": "tsc --noEmit", 50 | "lint": "eslint --ext ts src", 51 | "test:ci": "vitest run", 52 | "test": "vitest --watch", 53 | "upgrade": "npm-check-updates -u -x eslint && npm install" 54 | }, 55 | "devDependencies": { 56 | "@tuplo/shell": "1.2.2", 57 | "@types/node": "20.14.2", 58 | "@typescript-eslint/eslint-plugin": "7.13.0", 59 | "@typescript-eslint/parser": "7.13.0", 60 | "@vitest/coverage-v8": "1.6.0", 61 | "aws-sdk": "^2.1641.0", 62 | "esbuild": "0.21.5", 63 | "eslint": "8.56.0", 64 | "eslint-config-airbnb-base": "15.0.0", 65 | "eslint-config-airbnb-typescript": "18.0.0", 66 | "eslint-config-prettier": "9.1.0", 67 | "eslint-plugin-import": "2.29.1", 68 | "npm-check-updates": "16.14.20", 69 | "nyc": "17.0.0", 70 | "prettier": "3.3.2", 71 | "tsx": "4.15.4", 72 | "typescript": "5.4.5", 73 | "vitest": "1.6.0" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |
2 |
3 | Logo 4 | 5 |

dynoexpr

6 | 7 |

8 | Expression builder for AWS.DynamoDB.DocumentClient 9 |

10 |

11 | 12 | 13 | 14 | 15 |

16 | 17 |
18 | 19 | ## Introduction 20 | 21 | Converts a plain object to a DynamoDB expression with all variables and names 22 | replaced with safe placeholders. It supports `Condition`, `KeyCondition`, `Filter`, `Projection` and `Update` expressions. The resulting expressions can then be used with `AWS.DynamoDB.DocumentClient` requests. 23 | 24 | ```typescript 25 | import dynoexpr from '@tuplo/dynoexpr'; 26 | 27 | const params = dynoexpr({ 28 | KeyCondition: { id: '567' }, 29 | Condition: { rating: '> 4.5' }, 30 | Filter: { color: 'blue' }, 31 | Projection: ['weight', 'size'], 32 | }); 33 | 34 | /* 35 | { 36 | KeyConditionExpression: '(#nca40fdf5 = :v8dcca6b2)', 37 | ExpressionAttributeValues: { 38 | ':v8dcca6b2': '567', 39 | ':vc95fafc8': 4.5, 40 | ':v792aabee': 'blue' 41 | }, 42 | ConditionExpression: '(#n0f1c2905 > :vc95fafc8)', 43 | FilterExpression: '(#n2d334799 = :v792aabee)', 44 | ProjectionExpression: '#neb86488e,#n0367c420', 45 | ExpressionAttributeNames: { 46 | '#nca40fdf5': 'id', 47 | '#n0f1c2905': 'rating', 48 | '#n2d334799': 'color', 49 | '#neb86488e': 'weight', 50 | '#n0367c420': 'size' 51 | } 52 | } 53 | */ 54 | ``` 55 | 56 | ## Install 57 | 58 | ```bash 59 | $ npm install @tuplo/dynoexpr 60 | 61 | # or with yarn 62 | $ yarn add @tuplo/dynoexpr 63 | ``` 64 | 65 | ## Usage 66 | 67 | ### Passing parameters to DocumentClient 68 | 69 | ```typescript 70 | const docClient = new AWS.DynamoDB.DocumentClient(); 71 | 72 | const params = dynoexpr({ 73 | KeyCondition: { 74 | HashKey: 'key', 75 | RangeKey: 'between 2015 and 2019', 76 | }, 77 | }); 78 | 79 | const results = await docClient 80 | .query({ TableName: 'table', ...params }) 81 | .promise(); 82 | ``` 83 | 84 | ### Using multiple expressions on the same field 85 | 86 | You can use multiple expressions on the same field, by packing them into an array and assigning it to the key with the field's name. 87 | 88 | ```typescript 89 | const params = dynoexpr({ 90 | Condition: { 91 | color: ['attribute_not_exists', 'yellow', 'blue'], 92 | }, 93 | ConditionLogicalOperator: 'OR', 94 | }); 95 | 96 | /* 97 | { 98 | ConditionExpression: '(attribute_not_exists(#n2d334799)) OR (#n2d334799 = :v0d81c8cd) OR (#n2d334799 = :v792aabee)', 99 | ExpressionAttributeNames: { 100 | '#n2d334799': 'color' 101 | }, 102 | ExpressionAttributeValues: { 103 | ':v0d81c8cd': 'yellow', 104 | ':v792aabee': 'blue' 105 | } 106 | } 107 | */ 108 | ``` 109 | 110 | ### Using functions 111 | 112 | `DynamoDB` supports a number of [functions](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.OperatorsAndFunctions.html#Expressions.OperatorsAndFunctions.Functions) to be evaluated when parsing expressions. You don't need to reference the `path` argument because that's identified by the object's key. 113 | 114 | ```typescript 115 | const params = dynoexpr({ 116 | Condition: { 117 | docs: 'attribute_exists', 118 | brand: 'attribute_not_exists', 119 | extra: 'attribute_type(NULL)', 120 | color: 'begins_with dark', 121 | address: 'contains(Seattle)', 122 | description: 'size < 20', 123 | }, 124 | }); 125 | 126 | /* 127 | { 128 | ConditionExpression: '(attribute_exists(#nd0a55266)) AND (attribute_not_exists(#n4e5f8507)) AND (attribute_type(#n4a177797,:v64b0a475)) AND (begins_with(#n2d334799,:v1fdc3f67)) AND (contains(#n3af77f77,:v26425a2a)) AND (size(#nb6c8f268) < :vde9019e3)', 129 | ExpressionAttributeNames: { 130 | '#nd0a55266': 'docs', 131 | '#n4e5f8507': 'brand', 132 | '#n4a177797': 'extra', 133 | '#n2d334799': 'color', 134 | '#n3af77f77': 'address', 135 | '#nb6c8f268': 'description' 136 | }, 137 | ExpressionAttributeValues: { 138 | ':v64b0a475': 'NULL', 139 | ':v1fdc3f67': 'dark', 140 | ':v26425a2a': 'Seattle', 141 | ':vde9019e3': 20 142 | } 143 | } 144 | */ 145 | ``` 146 | 147 | ### Using multiple expressions on the same request 148 | 149 | ```typescript 150 | const params = dynoexpr({ 151 | Update: { Sum: 'Sum + 20' }, 152 | Condition: { Sum: `< 100` }, 153 | }); 154 | 155 | /* 156 | { 157 | ConditionExpression: '(#n5af617ef < :va88c83b0)', 158 | ExpressionAttributeNames: { 159 | '#n5af617ef': 'Sum' 160 | }, 161 | ExpressionAttributeValues: { 162 | ':va88c83b0': 100, 163 | ':vde9019e3': 20 164 | }, 165 | UpdateExpression: 'SET #n5af617ef = #n5af617ef + :vde9019e3' 166 | } 167 | */ 168 | ``` 169 | 170 | ### Working with Sets 171 | 172 | If a value is provided as a Set, it will be converted to `DocumentClient.DynamoDbSet`. But `dynoexpr` doesn't include `DocumentClient` so you need to provide it. 173 | 174 | ```typescript 175 | import { DocumentClient } from "aws-sdk/clients/dynamodb"; 176 | 177 | const params = dynoexpr({ 178 | DocumentClient, 179 | Update: { 180 | Color: new Set(['Orange', 'Purple']) 181 | }, 182 | }) 183 | 184 | /* 185 | { 186 | UpdateExpression: 'SET #n8979552b = :v3add0a80', 187 | ExpressionAttributeNames: { 188 | '#n8979552b': 'Color' 189 | }, 190 | ExpressionAttributeValues: { 191 | ':v3add0a80': Set { wrapperName: 'Set', values: [Array], type: 'String' } 192 | } 193 | } 194 | */ 195 | ``` 196 | 197 | #### When using UpdateAdd or UpdateDelete, arrays are converted to DynamoDbSet 198 | 199 | ```typescript 200 | import { DocumentClient } from "aws-sdk/clients/dynamodb"; 201 | 202 | const params = dynoexpr({ 203 | DocumentClient, 204 | UpdateAdd: { 205 | Color: ['Orange', 'Purple'] 206 | } 207 | }) 208 | 209 | /* 210 | { 211 | UpdateExpression: 'ADD #ndc9f7295 :v3add0a80', 212 | ExpressionAttributeNames: { 213 | '#ndc9f7295': 'Color' 214 | }, 215 | ExpressionAttributeValues: { 216 | ':v3add0a80': Set { wrapperName: 'Set', values: [Array], type: 'String' } 217 | } 218 | } 219 | */ 220 | ``` 221 | 222 | ### Keep existing Expressions, AttributeNames and AttributeValues 223 | 224 | ```typescript 225 | const params = dynoexpr({ 226 | Filter: { color: 'blue' }, 227 | ProjectionExpression: '#year', 228 | ExpressionAttributeNames: { 229 | '#year': 'year', 230 | }, 231 | }); 232 | 233 | /* 234 | { 235 | ProjectionExpression: '#year', 236 | ExpressionAttributeNames: { 237 | '#year': 'year', 238 | '#n2d334799': 'color' 239 | }, 240 | FilterExpression: '(#n2d334799 = :v792aabee)', 241 | ExpressionAttributeValues: { 242 | ':v792aabee': 'blue' 243 | } 244 | } 245 | */ 246 | ``` 247 | 248 | ### Using object paths on expressions 249 | 250 | You can provide a path to an attribute on a deep object, each node will be escaped. 251 | 252 | ```typescript 253 | const params = dynoexpr({ 254 | Update: { 255 | 'foo.bar.baz': 'foo.bar.baz + 1' 256 | } 257 | }); 258 | 259 | /* 260 | { 261 | ExpressionAttributeNames: { 262 | "#n22f4f0ae": "bar", 263 | "#n5f0025bb": "foo", 264 | "#n82504b33": "baz", 265 | }, 266 | ExpressionAttributeValues: { 267 | ":vc823bd86": 1, 268 | }, 269 | UpdateExpression: 270 | "SET #n5f0025bb.#n22f4f0ae.#n82504b33 = #n5f0025bb.#n22f4f0ae.#n82504b33 + :vc823bd86", 271 | } 272 | */ 273 | 274 | ``` 275 | 276 | If one of the nodes needs to escape some of its characters, use double quotes around it, like this: 277 | 278 | ```typescript 279 | const params = dynoexpr({ 280 | Update: { 281 | 'foo."bar-cuz".baz': 'foo."bar-cuz".baz + 1' 282 | } 283 | }); 284 | ``` 285 | 286 | 287 | ### Parsing atomic requests, only expressions will be replaced 288 | 289 | You can pass the whole request parameters to `dynoexpr` - only the expression builders will be replaced. 290 | 291 | ```typescript 292 | const params = dynoexpr({ 293 | TableName: 'Table', 294 | Key: { HashKey: 'key' }, 295 | ReturnConsumedCapacity: 'TOTAL', 296 | KeyCondition: { 297 | color: 'begins_with dark', 298 | }, 299 | }); 300 | 301 | /* 302 | { 303 | TableName: 'Table', 304 | Key: { HashKey: 'key' }, 305 | ReturnConsumedCapacity: 'TOTAL', 306 | KeyConditionExpression: '(begins_with(#n2d334799,:v1fdc3f67))', 307 | ExpressionAttributeNames: { 308 | '#n2d334799': 'color' 309 | }, 310 | ExpressionAttributeValues: { 311 | ':v1fdc3f67': 'dark' 312 | } 313 | } 314 | */ 315 | ``` 316 | 317 | ### Parsing Batch requests 318 | 319 | ```typescript 320 | const params = dynoexpr({ 321 | RequestItems: { 322 | 'Table-1': { 323 | Keys: [{ foo: 'bar' }], 324 | Projection: ['a', 'b'], 325 | }, 326 | }, 327 | ReturnConsumedCapacity: 'TOTAL', 328 | }); 329 | 330 | /* 331 | { 332 | "RequestItems":{ 333 | "Table-1":{ 334 | "Keys": [{"foo":"bar"}], 335 | "ProjectionExpression": "#na0f0d7ff,#ne4645342", 336 | "ExpressionAttributeNames":{ 337 | "#na0f0d7ff": "a", 338 | "#ne4645342": "b" 339 | } 340 | } 341 | }, 342 | "ReturnConsumedCapacity": "TOTAL" 343 | } 344 | */ 345 | ``` 346 | 347 | ### Parsing Transact requests 348 | 349 | ```typescript 350 | const params = dynoexpr({ 351 | TransactItems: [{ 352 | Get: { 353 | TableName: 'Table-1', 354 | Key: { id: 'foo' }, 355 | Projection: ['a', 'b'], 356 | }, 357 | }], 358 | ReturnConsumedCapacity: 'INDEXES', 359 | }); 360 | 361 | /* 362 | { 363 | "TransactItems": [ 364 | { 365 | "Get": { 366 | "TableName": "Table-1", 367 | "Key": { "id": "foo" }, 368 | "ProjectionExpression": "#na0f0d7ff,#ne4645342", 369 | "ExpressionAttributeNames": { 370 | "#na0f0d7ff":"a", 371 | "#ne4645342":"b" 372 | } 373 | } 374 | } 375 | ], 376 | "ReturnConsumedCapacity": "INDEXES" 377 | } 378 | */ 379 | ``` 380 | 381 | ### Type the resulting parameters 382 | 383 | The resulting object is compatible with all `DocumentClient` requests, but if you want to be type-safe, `dynoexpr` accepts a generic type to be applied to the return value. 384 | 385 | ```typescript 386 | const params = dynoexpr({ 387 | TableName: 'Table', 388 | Key: 1, 389 | UpdateSet: { color: 'pink' }, 390 | }); 391 | ``` 392 | 393 | ## API 394 | 395 | ### dynoexpr<T>(params) 396 | 397 | #### `params` 398 | 399 | Expression builder parameters 400 | 401 | ```typescript 402 | type DynamoDbPrimitive = string | number | boolean | object; 403 | type DynamoDbValue = 404 | | DynamoDbPrimitive 405 | | DynamoDbPrimitive[] 406 | | Set; 407 | 408 | // all attributes are optional, depending on what expression(s) are to be built 409 | { 410 | Condition: { [key: string]: DynamoDbValue }, 411 | ConditionLogicalOperator: 'AND' | 'OR', 412 | 413 | KeyCondition: { [key: string]: DynamoDbValue }, 414 | KeyConditionLogicalOperator: 'AND' | 'OR', 415 | 416 | FilterCondition: { [key: string]: DynamoDbValue }, 417 | FilterLogicalOperator: 'AND' | 'OR', 418 | 419 | Projection: string[], 420 | 421 | Update: { [key: string]: DynamoDbValue }, 422 | UpdateAction: 'SET' | 'ADD' | 'DELETE' | 'REMOVE', 423 | 424 | UpdateSet: { [key: string]: DynamoDbValue }, 425 | UpdateAdd: { [key: string]: DynamoDbValue }, 426 | UpdateDelete: { [key: string]: DynamoDbValue }, 427 | UpdateRemove: { [key: string]: DynamoDbValue }, 428 | 429 | DocumentClient: AWS.DynamoDB.DocumentClient 430 | } 431 | ``` 432 | 433 | #### Return value 434 | 435 | Parameters accepted by `AWS.DynamoDB.DocumentClient` 436 | 437 | ```typescript 438 | // all attributes are optional depending on the expression(s) being built 439 | { 440 | ConditionExpression: string, 441 | 442 | KeyConditionExpression: string, 443 | 444 | FilterConditionExpression: string, 445 | 446 | ProjectionExpression: string, 447 | 448 | UpdateExpression: string, 449 | 450 | ExpressionAttributeNames: { [key: string]: string }, 451 | ExpressionAttributeValues: { [key: string]: string }, 452 | } 453 | ``` 454 | 455 | ## License 456 | 457 | MIT 458 | -------------------------------------------------------------------------------- /sh/build.ts: -------------------------------------------------------------------------------- 1 | import * as shell from "@tuplo/shell"; 2 | 3 | async function main() { 4 | const $ = shell.$({ verbose: true }); 5 | 6 | await $`rm -rf dist`; 7 | await $`tsc --project tsconfig.build.json`; 8 | 9 | const flags = ["--bundle", "--platform=node"]; 10 | 11 | await $`esbuild src/cjs/index.cjs --outfile=dist/index.cjs ${flags}`; 12 | await $`esbuild src/index.ts --format=esm --outfile=dist/index.mjs ${flags}`; 13 | 14 | await $`cp src/dynoexpr.d.ts dist/dynoexpr.d.ts`; 15 | } 16 | 17 | main(); 18 | -------------------------------------------------------------------------------- /sh/coverage.ts: -------------------------------------------------------------------------------- 1 | import * as shell from "@tuplo/shell"; 2 | 3 | async function main() { 4 | const $ = shell.$({ verbose: true }); 5 | 6 | await $`rm -rf ./node_modules/.cache`; 7 | await $`rm -rf coverage/`; 8 | await $`rm -rf .nyc_output/`; 9 | 10 | const flags = ["--coverage true"].flatMap((f) => f.split(" ")); 11 | await $`NODE_ENV=test LOG_LEVEL=silent nyc npm run test:ci -- ${flags}`; 12 | } 13 | 14 | main(); 15 | -------------------------------------------------------------------------------- /sh/dev.ts: -------------------------------------------------------------------------------- 1 | import * as shell from "@tuplo/shell"; 2 | 3 | async function main() { 4 | const $ = shell.$({ verbose: true }); 5 | 6 | const flags = [ 7 | "--bundle", 8 | "--watch", 9 | "--format=esm", 10 | "--platform=node", 11 | "--outfile=dist/index.js", 12 | "--external:aws-sdk", 13 | ]; 14 | 15 | await $`esbuild src/index.ts ${flags}`; 16 | } 17 | 18 | main(); 19 | -------------------------------------------------------------------------------- /src/bug-reports.test.ts: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | 3 | import dynoexpr from "./index"; 4 | 5 | describe("bug reports", () => { 6 | it("logical operator", () => { 7 | const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(1646249594000); 8 | const args = { 9 | Update: { 10 | modified: new Date(Date.now()).toJSON(), 11 | GSI1_PK: "OPEN", 12 | GSI2_PK: "REQUEST#STATUS#open#DATE#2022-03-01T13:58:09.242z", 13 | }, 14 | Condition: { 15 | status: ["IN_PROGRESS", "OPEN"], 16 | }, 17 | ConditionLogicalOperator: "OR", 18 | }; 19 | const actual = dynoexpr(args); 20 | 21 | const expected = { 22 | ConditionExpression: 23 | "(#nfc6b756c = :v84e520b4) OR (#nfc6b756c = :ve0304017)", 24 | ExpressionAttributeNames: { 25 | "#n3974b0c4": "GSI1_PK", 26 | "#nfc6b756c": "status", 27 | "#n93dbb70d": "modified", 28 | "#n87f01ccc": "GSI2_PK", 29 | }, 30 | ExpressionAttributeValues: { 31 | ":vb60424a8": "2022-03-02T19:33:14.000Z", 32 | ":v985d200a": "REQUEST#STATUS#open#DATE#2022-03-01T13:58:09.242z", 33 | ":v84e520b4": "IN_PROGRESS", 34 | ":ve0304017": "OPEN", 35 | }, 36 | UpdateExpression: 37 | "SET #n93dbb70d = :vb60424a8, #n3974b0c4 = :ve0304017, #n87f01ccc = :v985d200a", 38 | }; 39 | expect(actual).toStrictEqual(expected); 40 | 41 | dateNowSpy.mockRestore(); 42 | }); 43 | 44 | it("supports if_not_exists on update expressions", () => { 45 | const args = { 46 | Update: { number: "if_not_exists(420)" }, 47 | }; 48 | const actual = dynoexpr(args); 49 | 50 | const expected = { 51 | UpdateExpression: 52 | "SET #nc66bcf16 = if_not_exists(#nc66bcf16, :v70d78b9d)", 53 | ExpressionAttributeNames: { "#nc66bcf16": "number" }, 54 | ExpressionAttributeValues: { ":v70d78b9d": "420" }, 55 | }; 56 | expect(actual).toStrictEqual(expected); 57 | }); 58 | 59 | it("allows boolean values", () => { 60 | const Filter = { 61 | a: "<> true", 62 | b: "<> false", 63 | }; 64 | const args = { Filter }; 65 | const actual = dynoexpr(args); 66 | 67 | const expected = { 68 | ExpressionAttributeNames: { "#na0f0d7ff": "a", "#ne4645342": "b" }, 69 | ExpressionAttributeValues: { ":v976fa742": false, ":vc86ac629": true }, 70 | FilterExpression: 71 | "(#na0f0d7ff <> :vc86ac629) AND (#ne4645342 <> :v976fa742)", 72 | }; 73 | expect(actual).toStrictEqual(expected); 74 | }); 75 | 76 | it("empty ExpressionAttributeValues on UpdateRemove with Condition", () => { 77 | const args = { 78 | UpdateRemove: { "parent.item": 1 }, 79 | Condition: { "parent.item": "attribute_exists" }, 80 | }; 81 | const actual = dynoexpr(args); 82 | 83 | const expected = { 84 | ConditionExpression: "(attribute_exists(#ndae5997d.#ncc96b5ad))", 85 | ExpressionAttributeNames: { 86 | "#ncc96b5ad": "item", 87 | "#ndae5997d": "parent", 88 | }, 89 | UpdateExpression: "REMOVE #ndae5997d.#ncc96b5ad", 90 | }; 91 | expect(actual).toStrictEqual(expected); 92 | }); 93 | 94 | it("pass undefined to UpdateRemove", () => { 95 | const args = { 96 | UpdateRemove: { "parent.item": undefined }, 97 | Condition: { "parent.item": "attribute_exists" }, 98 | }; 99 | const actual = dynoexpr(args); 100 | 101 | const expected = { 102 | ConditionExpression: "(attribute_exists(#ndae5997d.#ncc96b5ad))", 103 | ExpressionAttributeNames: { 104 | "#ncc96b5ad": "item", 105 | "#ndae5997d": "parent", 106 | }, 107 | UpdateExpression: "REMOVE #ndae5997d.#ncc96b5ad", 108 | }; 109 | expect(actual).toStrictEqual(expected); 110 | }); 111 | 112 | it("handles list_append", () => { 113 | const args = { 114 | Update: { numbersArray: "list_append([1, 2], numbersArray)" }, 115 | }; 116 | const actual = dynoexpr(args); 117 | 118 | const expected = { 119 | UpdateExpression: "SET #ne0c11d8d = list_append(:v31e6eb45, #ne0c11d8d)", 120 | ExpressionAttributeNames: { "#ne0c11d8d": "numbersArray" }, 121 | ExpressionAttributeValues: { ":v31e6eb45": [1, 2] }, 122 | }; 123 | expect(actual).toStrictEqual(expected); 124 | }); 125 | 126 | it("handles list_append with strings", () => { 127 | const args = { 128 | Update: { numbersArray: 'list_append(["a", "b"], numbersArray)' }, 129 | }; 130 | const actual = dynoexpr(args); 131 | 132 | const expected = { 133 | UpdateExpression: "SET #ne0c11d8d = list_append(:v3578c5eb, #ne0c11d8d)", 134 | ExpressionAttributeNames: { "#ne0c11d8d": "numbersArray" }, 135 | ExpressionAttributeValues: { ":v3578c5eb": ["a", "b"] }, 136 | }; 137 | expect(actual).toStrictEqual(expected); 138 | }); 139 | 140 | it("handles composite keys on updates with math operations", () => { 141 | const args = { 142 | Update: { 143 | "foo.bar.baz": "foo.bar.baz + 1", 144 | }, 145 | }; 146 | const actual = dynoexpr(args); 147 | 148 | const expected = { 149 | ExpressionAttributeNames: { 150 | "#n22f4f0ae": "bar", 151 | "#n5f0025bb": "foo", 152 | "#n82504b33": "baz", 153 | }, 154 | ExpressionAttributeValues: { 155 | ":vc823bd86": 1, 156 | }, 157 | UpdateExpression: 158 | "SET #n5f0025bb.#n22f4f0ae.#n82504b33 = #n5f0025bb.#n22f4f0ae.#n82504b33 + :vc823bd86", 159 | }; 160 | expect(actual).toStrictEqual(expected); 161 | }); 162 | 163 | it("escape dynamic keys in objects", () => { 164 | const dynamicKey = "key.with-chars"; 165 | const args = { 166 | Update: { 167 | [`object."${dynamicKey}".value`]: `object."${dynamicKey}".value + 1`, 168 | }, 169 | Condition: { [`object."${dynamicKey}".value`]: "> 2" }, 170 | }; 171 | const actual = dynoexpr(args); 172 | 173 | const expected = { 174 | ConditionExpression: "(#nbb017076.#n0327a04a.#n10d6f4c5 > :vaeeabc63)", 175 | ExpressionAttributeNames: { 176 | "#nbb017076": "object", 177 | "#n0327a04a": "key.with-chars", 178 | "#n10d6f4c5": "value", 179 | }, 180 | ExpressionAttributeValues: { 181 | ":vaeeabc63": 2, 182 | ":vc823bd86": 1, 183 | }, 184 | UpdateExpression: 185 | "SET #nbb017076.#n0327a04a.#n10d6f4c5 = #nbb017076.#n0327a04a.#n10d6f4c5 + :vc823bd86", 186 | }; 187 | expect(actual).toStrictEqual(expected); 188 | }); 189 | }); 190 | -------------------------------------------------------------------------------- /src/cjs/index.cjs: -------------------------------------------------------------------------------- 1 | import dynoexpr from "../index"; 2 | 3 | module.exports = dynoexpr; 4 | -------------------------------------------------------------------------------- /src/document-client.test.ts: -------------------------------------------------------------------------------- 1 | import { DocumentClient as DocClientV2 } from "aws-sdk/clients/dynamodb"; 2 | 3 | import { AwsSdkDocumentClient } from "./document-client"; 4 | 5 | describe("aws sdk document client", () => { 6 | afterEach(() => { 7 | AwsSdkDocumentClient.setDocumentClient(null); 8 | }); 9 | 10 | it("throws an error when there's no AWS SKD provided", () => { 11 | const docClient = new AwsSdkDocumentClient(); 12 | const fn = () => docClient.createSet([1, 2, 3]); 13 | 14 | const expected = 15 | "dynoexpr: When working with Sets, please provide the AWS DocumentClient (v2)."; 16 | expect(fn).toThrowError(expected); 17 | }); 18 | 19 | it("creates a AWS Set using AWS SDK DocumentClient v2", () => { 20 | AwsSdkDocumentClient.setDocumentClient(DocClientV2); 21 | const docClient = new AwsSdkDocumentClient(); 22 | const actual = docClient.createSet([1, 2, 3]); 23 | 24 | const awsDocClient = new DocClientV2(); 25 | const expected = awsDocClient.createSet([1, 2, 3]); 26 | expect(actual).toStrictEqual(expected); 27 | }); 28 | 29 | describe("creates sets", () => { 30 | const docClient = new AwsSdkDocumentClient(); 31 | 32 | beforeEach(() => { 33 | AwsSdkDocumentClient.setDocumentClient(DocClientV2); 34 | }); 35 | 36 | it("creates DynamoDBSet instances for strings", () => { 37 | const args = ["hello", "world"]; 38 | const actual = docClient.createSet(args); 39 | 40 | expect(actual.type).toBe("String"); 41 | expect(actual.values).toHaveLength(args.length); 42 | expect(actual.values).toContain("hello"); 43 | expect(actual.values).toContain("world"); 44 | }); 45 | 46 | it("creates DynamoDBSet instances for numbers", () => { 47 | const args = [42, 1, 2]; 48 | const actual = docClient.createSet(args); 49 | 50 | expect(actual.type).toBe("Number"); 51 | expect(actual.values).toHaveLength(args.length); 52 | expect(actual.values).toContain(42); 53 | expect(actual.values).toContain(1); 54 | expect(actual.values).toContain(2); 55 | }); 56 | 57 | it("creates DynamoDBSet instances for binary types", () => { 58 | const args = [ 59 | Buffer.from([0x62, 0x75, 0x66, 0x66, 0x65, 0x72]), 60 | Buffer.from([0x61, 0x62, 0x63]), 61 | ]; 62 | const actual = docClient.createSet(args); 63 | 64 | expect(actual.type).toBe("Binary"); 65 | expect(actual.values).toHaveLength(args.length); 66 | expect(actual.values).toContainEqual(args[0]); 67 | expect(actual.values).toContain(args[1]); 68 | }); 69 | 70 | it("does not throw an error with mixed set types if validation is not explicitly enabled", () => { 71 | const args = ["hello", 42]; 72 | const actual = docClient.createSet(args); 73 | 74 | expect(actual.type).toBe("String"); 75 | expect(actual.values).toHaveLength(args.length); 76 | expect(actual.values).toContain("hello"); 77 | expect(actual.values).toContain(42); 78 | }); 79 | 80 | it("throws an error with mixed set types if validation is enabled", () => { 81 | const params = ["hello", 42]; 82 | const expression = () => docClient.createSet(params, { validate: true }); 83 | 84 | const expected = "String Set contains Number value"; 85 | expect(expression).toThrow(expected); 86 | }); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /src/document-client.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable class-methods-use-this */ 2 | let AwsSdk: unknown = null; 3 | 4 | export class AwsSdkDocumentClient { 5 | static setDocumentClient(clientAwsSdk: unknown) { 6 | AwsSdk = clientAwsSdk; 7 | } 8 | 9 | createSet( 10 | list: unknown[] | Record, 11 | options?: Record 12 | ) { 13 | if (!AwsSdk) { 14 | throw Error( 15 | "dynoexpr: When working with Sets, please provide the AWS DocumentClient (v2)." 16 | ); 17 | } 18 | 19 | // @ts-expect-error Property 'prototype' does not exist on type '{}'. 20 | return AwsSdk.prototype.createSet(list, options); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/dynoexpr.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/indent */ 2 | type ILogicalOperatorType = string | "AND" | "OR"; 3 | 4 | type IDynoexprInputValue = 5 | | string 6 | | string[] 7 | | number 8 | | number[] 9 | | boolean 10 | | boolean[] 11 | | Record 12 | | Record[] 13 | | Set 14 | | Set 15 | | null 16 | | undefined; 17 | 18 | type IDynamoDbValue = 19 | | string 20 | | string[] 21 | | number 22 | | number[] 23 | | boolean 24 | | boolean[] 25 | | Record 26 | | Record[] 27 | | null 28 | | unknown; 29 | 30 | interface IExpressionAttributeNames { 31 | [key: string]: string; 32 | } 33 | interface IExpressionAttributeValues { 34 | [key: string]: IDynamoDbValue; 35 | } 36 | 37 | interface IExpressionAttributes { 38 | ExpressionAttributeNames?: IExpressionAttributeNames; 39 | ExpressionAttributeValues?: IExpressionAttributeValues; 40 | } 41 | 42 | // batch operations 43 | interface IBatchGetInput extends IProjectionInput { 44 | [key: string]: unknown; 45 | } 46 | interface IBatchWriteInput { 47 | DeleteRequest?: unknown; 48 | PutRequest?: unknown; 49 | } 50 | interface IBatchRequestItemsInput { 51 | [key: string]: IBatchGetInput | IBatchWriteInput[]; 52 | } 53 | export interface IBatchRequestInput { 54 | RequestItems: IBatchRequestItemsInput; 55 | [key: string]: unknown; 56 | } 57 | interface IBatchRequestOutput { 58 | RequestItems: IProjectionOutput & Record; 59 | [key: string]: unknown; 60 | } 61 | 62 | // transact operations 63 | type ITransactOperation = 64 | | "Get" 65 | | "ConditionCheck" 66 | | "Put" 67 | | "Delete" 68 | | "Update"; 69 | 70 | type ITransactRequestItems = Partial< 71 | Record 72 | >; 73 | export interface ITransactRequestInput { 74 | TransactItems: ITransactRequestItems[]; 75 | [key: string]: unknown; 76 | } 77 | interface ITransactRequestOutput { 78 | TransactItems: ITransactRequestItems[]; 79 | [key: string]: unknown; 80 | } 81 | 82 | // Condition 83 | interface ICondition { 84 | [key: string]: IDynoexprInputValue; 85 | } 86 | interface IConditionInput extends IExpressionAttributes { 87 | Condition?: ICondition; 88 | ConditionLogicalOperator?: ILogicalOperatorType; 89 | [key: string]: unknown; 90 | } 91 | interface IConditionOutput extends IExpressionAttributes { 92 | ConditionExpression?: string; 93 | [key: string]: unknown; 94 | } 95 | 96 | // KeyCondition 97 | interface IKeyCondition { 98 | [key: string]: IDynoexprInputValue; 99 | } 100 | interface IKeyConditionInput extends IExpressionAttributes { 101 | KeyCondition?: IKeyCondition; 102 | KeyConditionLogicalOperator?: ILogicalOperatorType; 103 | [key: string]: unknown; 104 | } 105 | interface IKeyConditionOutput extends IExpressionAttributes { 106 | KeyConditionExpression?: string; 107 | } 108 | 109 | // Filter 110 | interface IFilter { 111 | [key: string]: IDynoexprInputValue; 112 | } 113 | interface IFilterInput extends IExpressionAttributes { 114 | Filter?: IFilter; 115 | FilterLogicalOperator?: ILogicalOperatorType; 116 | [key: string]: unknown; 117 | } 118 | interface IFilterOutput extends IExpressionAttributes { 119 | FilterExpression?: string; 120 | } 121 | 122 | // Projection 123 | type IProjection = string[]; 124 | interface IProjectionInput { 125 | Projection?: IProjection; 126 | ExpressionAttributeNames?: IExpressionAttributeNames; 127 | [key: string]: unknown; 128 | } 129 | interface IProjectionOutput extends IExpressionAttributes { 130 | ProjectionExpression?: string; 131 | ExpressionAttributeNames?: IExpressionAttributeNames; 132 | } 133 | 134 | // Update 135 | interface IUpdate { 136 | [key: string]: IDynoexprInputValue; 137 | } 138 | type IUpdateAction = "SET" | "ADD" | "DELETE" | "REMOVE"; 139 | interface IUpdateInput extends IExpressionAttributes { 140 | Update?: IUpdate; 141 | UpdateAction?: IUpdateAction; 142 | UpdateRemove?: IUpdate; 143 | UpdateAdd?: IUpdate; 144 | UpdateSet?: IUpdate; 145 | UpdateDelete?: IUpdate; 146 | [key: string]: unknown; 147 | } 148 | interface IUpdateOutput extends IExpressionAttributes { 149 | UpdateExpression?: string; 150 | [key: string]: unknown; 151 | } 152 | 153 | export interface IDynoexprInput 154 | extends IConditionInput, 155 | IFilterInput, 156 | IKeyConditionInput, 157 | IProjectionInput, 158 | IUpdateInput { 159 | [key: string]: unknown; 160 | } 161 | 162 | export interface IDynoexprOutput 163 | extends IConditionOutput, 164 | IFilterOutput, 165 | IKeyConditionOutput, 166 | IProjectionOutput, 167 | IUpdateOutput { 168 | [key: string]: unknown; 169 | } 170 | -------------------------------------------------------------------------------- /src/expressions/condition.test.ts: -------------------------------------------------------------------------------- 1 | import type { IConditionInput } from "src/dynoexpr.d"; 2 | 3 | import { getConditionExpression } from "./condition"; 4 | 5 | describe("condition expression", () => { 6 | it("builds the ConditionExpression and NameValueMaps - comparison operators", () => { 7 | const Condition = { 8 | a: "foo", 9 | b: "> 1", 10 | c: ">= 2", 11 | d: "< 3", 12 | e: "<= 4", 13 | f: "<> 5", 14 | fa: "<> true", 15 | g: "BETWEEN 6 AND 7", 16 | h: "IN (foo, bar)", 17 | }; 18 | const args: IConditionInput = { Condition }; 19 | const actual = getConditionExpression(args); 20 | 21 | const expected = { 22 | ConditionExpression: [ 23 | "#na0f0d7ff = :v5f0025bb", 24 | "#ne4645342 > :vc823bd86", 25 | "#n54601b21 >= :vaeeabc63", 26 | "#nae599c14 < :vf13631fc", 27 | "#n7c866780 <= :vdd20580d", 28 | "#n79761749 <> :v77e3e295", 29 | "#n14e68f2d <> :vc86ac629", 30 | "#n42f580fe between :vde135ba3 and :v11392247", 31 | "#ne38a286c in (:v5f0025bb,:v22f4f0ae)", 32 | ] 33 | .map((exp) => `(${exp})`) 34 | .join(" AND "), 35 | ExpressionAttributeNames: { 36 | "#na0f0d7ff": "a", 37 | "#ne4645342": "b", 38 | "#n54601b21": "c", 39 | "#nae599c14": "d", 40 | "#n7c866780": "e", 41 | "#n79761749": "f", 42 | "#n14e68f2d": "fa", 43 | "#n42f580fe": "g", 44 | "#ne38a286c": "h", 45 | }, 46 | ExpressionAttributeValues: { 47 | ":vc823bd86": 1, 48 | ":vaeeabc63": 2, 49 | ":vf13631fc": 3, 50 | ":vdd20580d": 4, 51 | ":v77e3e295": 5, 52 | ":vde135ba3": 6, 53 | ":v11392247": 7, 54 | ":v5f0025bb": "foo", 55 | ":v22f4f0ae": "bar", 56 | ":vc86ac629": true, 57 | }, 58 | }; 59 | expect(actual).toStrictEqual(expected); 60 | }); 61 | 62 | it("builds the ConditionExpression and NameValueMaps - function", () => { 63 | const Condition = { 64 | a: "attribute_exists", 65 | b: "attribute_not_exists", 66 | c: "attribute_type(S)", 67 | d: "begins_with(foo)", 68 | e: "contains(foo)", 69 | f: "size > 10", 70 | }; 71 | const args: IConditionInput = { Condition }; 72 | const actual = getConditionExpression(args); 73 | 74 | const expected = { 75 | ConditionExpression: [ 76 | "attribute_exists(#na0f0d7ff)", 77 | "attribute_not_exists(#ne4645342)", 78 | "attribute_type(#n54601b21,:va6a17c2f)", 79 | "begins_with(#nae599c14,:v5f0025bb)", 80 | "contains(#n7c866780,:v5f0025bb)", 81 | "size(#n79761749) > :va8d1f941", 82 | ] 83 | .map((exp) => `(${exp})`) 84 | .join(" AND "), 85 | ExpressionAttributeNames: { 86 | "#na0f0d7ff": "a", 87 | "#ne4645342": "b", 88 | "#n54601b21": "c", 89 | "#nae599c14": "d", 90 | "#n7c866780": "e", 91 | "#n79761749": "f", 92 | }, 93 | ExpressionAttributeValues: { 94 | ":va6a17c2f": "S", 95 | ":v5f0025bb": "foo", 96 | ":va8d1f941": 10, 97 | }, 98 | }; 99 | expect(actual).toStrictEqual(expected); 100 | }); 101 | 102 | it("builds the ConditionExpression and NameValueMaps - mixed operators", () => { 103 | const Condition = { 104 | a: 1, 105 | b: "between 2 and 3", 106 | c: "size > 4", 107 | }; 108 | const args: IConditionInput = { 109 | Condition, 110 | ConditionLogicalOperator: "OR", 111 | }; 112 | const actual = getConditionExpression(args); 113 | 114 | const expected = { 115 | ConditionExpression: [ 116 | "#na0f0d7ff = :vc823bd86", 117 | "#ne4645342 between :vaeeabc63 and :vf13631fc", 118 | "size(#n54601b21) > :vdd20580d", 119 | ] 120 | .map((exp) => `(${exp})`) 121 | .join(" OR "), 122 | ExpressionAttributeNames: { 123 | "#na0f0d7ff": "a", 124 | "#ne4645342": "b", 125 | "#n54601b21": "c", 126 | }, 127 | ExpressionAttributeValues: { 128 | ":vc823bd86": 1, 129 | ":vaeeabc63": 2, 130 | ":vf13631fc": 3, 131 | ":vdd20580d": 4, 132 | }, 133 | }; 134 | expect(actual).toStrictEqual(expected); 135 | }); 136 | 137 | it("builds the ConditionExpression and NameValueMaps - avoid erroring values map", () => { 138 | const Condition = { 139 | a: "attribute_exists", 140 | b: "attribute_not_exists", 141 | }; 142 | const args: IConditionInput = { Condition }; 143 | const actual = getConditionExpression(args); 144 | 145 | const expected = { 146 | ConditionExpression: [ 147 | "attribute_exists(#na0f0d7ff)", 148 | "attribute_not_exists(#ne4645342)", 149 | ] 150 | .map((exp) => `(${exp})`) 151 | .join(" AND "), 152 | ExpressionAttributeNames: { 153 | "#na0f0d7ff": "a", 154 | "#ne4645342": "b", 155 | }, 156 | }; 157 | expect(actual).toStrictEqual(expected); 158 | }); 159 | 160 | it("builds a ConditionalExpression with multiple expressions on the same field", () => { 161 | const Condition = { 162 | key: ["attribute_not_exists", "foobar"], 163 | }; 164 | const args: IConditionInput = { 165 | Condition, 166 | ConditionLogicalOperator: "OR", 167 | }; 168 | const actual = getConditionExpression(args); 169 | 170 | const expected = { 171 | ConditionExpression: 172 | "(attribute_not_exists(#nefd6a199)) OR (#nefd6a199 = :ve950eaf6)", 173 | ExpressionAttributeNames: { 174 | "#nefd6a199": "key", 175 | }, 176 | ExpressionAttributeValues: { 177 | ":ve950eaf6": "foobar", 178 | }, 179 | }; 180 | expect(actual).toStrictEqual(expected); 181 | }); 182 | }); 183 | -------------------------------------------------------------------------------- /src/expressions/condition.ts: -------------------------------------------------------------------------------- 1 | import type { IConditionInput, IConditionOutput } from "src/dynoexpr.d"; 2 | 3 | import { 4 | buildConditionAttributeNames, 5 | buildConditionAttributeValues, 6 | buildConditionExpression, 7 | } from "./helpers"; 8 | 9 | export function getConditionExpression(params: IConditionInput = {}) { 10 | if (!params.Condition) { 11 | return params; 12 | } 13 | 14 | const { Condition, ConditionLogicalOperator, ...restOfParams } = params; 15 | 16 | const ConditionExpression = buildConditionExpression({ 17 | Condition, 18 | LogicalOperator: ConditionLogicalOperator, 19 | }); 20 | 21 | const paramsWithConditions: IConditionOutput = { 22 | ...restOfParams, 23 | ConditionExpression, 24 | ExpressionAttributeNames: buildConditionAttributeNames(Condition, params), 25 | ExpressionAttributeValues: buildConditionAttributeValues(Condition, params), 26 | }; 27 | 28 | const { ExpressionAttributeNames, ExpressionAttributeValues } = 29 | paramsWithConditions; 30 | 31 | if (Object.keys(ExpressionAttributeNames || {}).length === 0) { 32 | delete paramsWithConditions.ExpressionAttributeNames; 33 | } 34 | 35 | if (Object.keys(ExpressionAttributeValues || {}).length === 0) { 36 | delete paramsWithConditions.ExpressionAttributeValues; 37 | } 38 | 39 | return paramsWithConditions; 40 | } 41 | -------------------------------------------------------------------------------- /src/expressions/filter.test.ts: -------------------------------------------------------------------------------- 1 | import type { IFilterInput } from "src/dynoexpr.d"; 2 | 3 | import { getFilterExpression } from "./filter"; 4 | 5 | describe("filter expression", () => { 6 | it("builds the FilterExpression and NameValueMaps - comparison operators", () => { 7 | const Filter = { 8 | a: "foo", 9 | b: "> 1", 10 | c: ">= 2", 11 | d: "< 3", 12 | e: "<= 4", 13 | f: "<> 5", 14 | fa: "<> true", 15 | g: "> six", 16 | h: ">= seven", 17 | i: "< eight", 18 | j: "<= nine", 19 | k: "<> ten", 20 | l: "BETWEEN 6 AND 7", 21 | m: "BETWEEN you AND me", 22 | n: "IN (foo, bar)", 23 | }; 24 | const args: IFilterInput = { Filter }; 25 | const actual = getFilterExpression(args); 26 | 27 | const expected = { 28 | FilterExpression: [ 29 | "#na0f0d7ff = :v5f0025bb", 30 | "#ne4645342 > :vc823bd86", 31 | "#n54601b21 >= :vaeeabc63", 32 | "#nae599c14 < :vf13631fc", 33 | "#n7c866780 <= :vdd20580d", 34 | "#n79761749 <> :v77e3e295", 35 | "#n14e68f2d <> :vc86ac629", 36 | "#n42f580fe > :v9ff5e5a8", 37 | "#ne38a286c >= :vf15a7556", 38 | "#n7892115e < :v91e83ab7", 39 | "#nc25f380c <= :vc215685d", 40 | "#n3cabadaa <> :vcd0bee5c", 41 | "#nc56d6c80 between :vde135ba3 and :v11392247", 42 | "#ne9b7120d between :ve5f8e70a and :v1ca860bf", 43 | "#ne692f12a in (:v5f0025bb,:v22f4f0ae)", 44 | ] 45 | .map((exp) => `(${exp})`) 46 | .join(" AND "), 47 | ExpressionAttributeNames: { 48 | "#nae599c14": "d", 49 | "#n79761749": "f", 50 | "#n42f580fe": "g", 51 | "#ne38a286c": "h", 52 | "#n54601b21": "c", 53 | "#n14e68f2d": "fa", 54 | "#na0f0d7ff": "a", 55 | "#ne4645342": "b", 56 | "#n7892115e": "i", 57 | "#nc56d6c80": "l", 58 | "#ne692f12a": "n", 59 | "#nc25f380c": "j", 60 | "#ne9b7120d": "m", 61 | "#n7c866780": "e", 62 | "#n3cabadaa": "k", 63 | }, 64 | ExpressionAttributeValues: { 65 | ":ve5f8e70a": "you", 66 | ":v9ff5e5a8": "six", 67 | ":v91e83ab7": "eight", 68 | ":vc215685d": "nine", 69 | ":v11392247": 7, 70 | ":v22f4f0ae": "bar", 71 | ":vc823bd86": 1, 72 | ":v1ca860bf": "me", 73 | ":v77e3e295": 5, 74 | ":vc86ac629": true, 75 | ":vdd20580d": 4, 76 | ":vde135ba3": 6, 77 | ":vcd0bee5c": "ten", 78 | ":vf15a7556": "seven", 79 | ":vaeeabc63": 2, 80 | ":v5f0025bb": "foo", 81 | ":vf13631fc": 3, 82 | }, 83 | }; 84 | expect(actual).toStrictEqual(expected); 85 | }); 86 | 87 | it("builds the FilterExpression and NameValueMaps - function", () => { 88 | const Filter = { 89 | a: "attribute_exists", 90 | b: "attribute_not_exists", 91 | c: "attribute_type(S)", 92 | d: "begins_with(foo)", 93 | e: "contains(foo)", 94 | f: "size > 10", 95 | }; 96 | const args: IFilterInput = { Filter }; 97 | const actual = getFilterExpression(args); 98 | 99 | const expected = { 100 | FilterExpression: [ 101 | "attribute_exists(#na0f0d7ff)", 102 | "attribute_not_exists(#ne4645342)", 103 | "attribute_type(#n54601b21,:va6a17c2f)", 104 | "begins_with(#nae599c14,:v5f0025bb)", 105 | "contains(#n7c866780,:v5f0025bb)", 106 | "size(#n79761749) > :va8d1f941", 107 | ] 108 | .map((exp) => `(${exp})`) 109 | .join(" AND "), 110 | ExpressionAttributeNames: { 111 | "#nae599c14": "d", 112 | "#n79761749": "f", 113 | "#n54601b21": "c", 114 | "#na0f0d7ff": "a", 115 | "#ne4645342": "b", 116 | "#n7c866780": "e", 117 | }, 118 | ExpressionAttributeValues: { 119 | ":va6a17c2f": "S", 120 | ":v5f0025bb": "foo", 121 | ":va8d1f941": 10, 122 | }, 123 | }; 124 | expect(actual).toStrictEqual(expected); 125 | }); 126 | 127 | it("builds the FilterExpression and NameValueMaps - mixed operators", () => { 128 | const Filter = { 129 | a: 1, 130 | b: "between 2 and 3", 131 | c: "size > 4", 132 | }; 133 | const args: IFilterInput = { Filter }; 134 | const actual = getFilterExpression(args); 135 | 136 | const expected = { 137 | FilterExpression: [ 138 | "#na0f0d7ff = :vc823bd86", 139 | "#ne4645342 between :vaeeabc63 and :vf13631fc", 140 | "size(#n54601b21) > :vdd20580d", 141 | ] 142 | .map((exp) => `(${exp})`) 143 | .join(" AND "), 144 | ExpressionAttributeNames: { 145 | "#na0f0d7ff": "a", 146 | "#ne4645342": "b", 147 | "#n54601b21": "c", 148 | }, 149 | ExpressionAttributeValues: { 150 | ":vc823bd86": 1, 151 | ":vdd20580d": 4, 152 | ":vaeeabc63": 2, 153 | ":vf13631fc": 3, 154 | }, 155 | }; 156 | expect(actual).toStrictEqual(expected); 157 | }); 158 | }); 159 | -------------------------------------------------------------------------------- /src/expressions/filter.ts: -------------------------------------------------------------------------------- 1 | import type { IFilterInput } from "src/dynoexpr.d"; 2 | 3 | import { 4 | buildConditionAttributeNames, 5 | buildConditionAttributeValues, 6 | buildConditionExpression, 7 | } from "./helpers"; 8 | 9 | export function getFilterExpression(params: IFilterInput = {}) { 10 | if (!params.Filter) { 11 | return params; 12 | } 13 | 14 | const { Filter, FilterLogicalOperator, ...restOfParams } = params; 15 | 16 | const FilterExpression = buildConditionExpression({ 17 | Condition: Filter, 18 | LogicalOperator: FilterLogicalOperator, 19 | }); 20 | 21 | const ExpressionAttributeNames = buildConditionAttributeNames(Filter, params); 22 | 23 | const ExpressionAttributeValues = buildConditionAttributeValues( 24 | Filter, 25 | params 26 | ); 27 | 28 | return { 29 | ...restOfParams, 30 | FilterExpression, 31 | ExpressionAttributeNames, 32 | ExpressionAttributeValues, 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /src/expressions/helpers.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | convertValue, 3 | buildConditionAttributeNames, 4 | buildConditionAttributeValues, 5 | buildConditionExpression, 6 | parseAttributeTypeValue, 7 | parseBeginsWithValue, 8 | parseBetweenValue, 9 | parseComparisonValue, 10 | parseContainsValue, 11 | parseInValue, 12 | parseNotCondition, 13 | parseSizeValue, 14 | } from "./helpers"; 15 | import type { 16 | IConditionAttributeNamesParams, 17 | IConditionAttributeValuesParams, 18 | } from "./helpers"; 19 | 20 | describe("helpers for condition helpers", () => { 21 | it.each([ 22 | ["foo", "foo"], 23 | ["true", true], 24 | ["false", false], 25 | ["truest", "truest"], 26 | ["falsest", "falsest"], 27 | ["null", null], 28 | ["123", 123], 29 | ["2.5", 2.5], 30 | ["123a", "123a"], 31 | ])("converts from string to primitive values: %s", (value, expected) => { 32 | const actual = convertValue(value); 33 | expect(actual).toBe(expected); 34 | }); 35 | 36 | describe("parse expression values", () => { 37 | it.each(["> 5", ">5", "> 5", ">=5", ">= 5", ">= 5"])( 38 | "comparison v: %s", 39 | (expr) => { 40 | const actual = parseComparisonValue(expr); 41 | expect(actual).toBe(5); 42 | } 43 | ); 44 | 45 | it.each([ 46 | "attribute_type(foo)", 47 | "attribute_type (foo)", 48 | "attribute_type (foo)", 49 | "attribute_type( foo )", 50 | ])("attribute_type(v): %s", (expr) => { 51 | const actual = parseAttributeTypeValue(expr); 52 | expect(actual).toBe("foo"); 53 | }); 54 | 55 | it.each([ 56 | "begins_with(foo)", 57 | "begins_with (foo)", 58 | "begins_with ( foo )", 59 | "BEGINS_WITH (foo)", 60 | "begins_with foo", 61 | "begins_with foo", 62 | ])("begins_with(v): %s", (expr) => { 63 | const actual = parseBeginsWithValue(expr); 64 | expect(actual).toBe("foo"); 65 | }); 66 | 67 | it.each(["between 1 and 2", "between 1 and 2"])( 68 | "between v1 and v2", 69 | (expr) => { 70 | const actual = parseBetweenValue(expr); 71 | expect(actual).toStrictEqual([1, 2]); 72 | } 73 | ); 74 | 75 | it.each([ 76 | "contains(foo)", 77 | "contains (foo)", 78 | "contains (foo)", 79 | "contains( foo )", 80 | "CONTAINS(foo)", 81 | ])("contains(v): %s", (expr) => { 82 | const actual = parseContainsValue(expr); 83 | expect(actual).toBe("foo"); 84 | }); 85 | 86 | it.each([ 87 | ["in(foo)", ["foo"]], 88 | ["in (foo)", ["foo"]], 89 | ["in (foo)", ["foo"]], 90 | ["in( foo )", ["foo"]], 91 | ["in(foo,bar,baz)", ["foo", "bar", "baz"]], 92 | ["in(foo, bar, baz)", ["foo", "bar", "baz"]], 93 | ["in(foo, bar, baz)", ["foo", "bar", "baz"]], 94 | ])("in(v1,v2,v3): %s", (expr, expected) => { 95 | const actual = parseInValue(expr); 96 | expect(actual).toStrictEqual(expected); 97 | }); 98 | 99 | it.each([ 100 | "size > 10", 101 | "size>10", 102 | "size >10", 103 | "size> 10", 104 | "SIZE>10", 105 | "size > 10", 106 | ])("size [op] v: %s", (expr) => { 107 | const actual = parseSizeValue(expr); 108 | expect(actual).toBe(10); 109 | }); 110 | 111 | it.each([ 112 | ["not contains(foo)", "contains(foo)"], 113 | ["not begins_with(foo)", "begins_with(foo)"], 114 | ["not begins_with(1)", "begins_with(1)"], 115 | ])("parse not conditions: %s", (expr, expected) => { 116 | const actual = parseNotCondition(expr); 117 | expect(actual).toBe(expected); 118 | }); 119 | }); 120 | 121 | describe("not expressions", () => { 122 | const Condition = { 123 | a: "not contains(foo)", 124 | b: "not begins_with(foo)", 125 | c: "not in(foo)", 126 | }; 127 | 128 | it("builds not conditions (expression)", () => { 129 | const actual = buildConditionExpression({ Condition }); 130 | 131 | const expected = [ 132 | "not contains(#na0f0d7ff,:v5f0025bb)", 133 | "not begins_with(#ne4645342,:v5f0025bb)", 134 | "not #n54601b21 in (:v5f0025bb)", 135 | ] 136 | .map((exp) => `(${exp})`) 137 | .join(" AND "); 138 | expect(actual).toStrictEqual(expected); 139 | }); 140 | 141 | it("builds not conditions (values)", () => { 142 | const result = buildConditionAttributeValues(Condition); 143 | 144 | const expected = { ":v5f0025bb": "foo" }; 145 | expect(result).toStrictEqual(expected); 146 | }); 147 | }); 148 | 149 | describe("comparison operators", () => { 150 | const Condition = { 151 | a: "foo", 152 | b: "> 1", 153 | c: ">= 2", 154 | d: "< 3", 155 | e: "<= 4", 156 | f: "<> 5", 157 | g: "BETWEEN 6 AND 7", 158 | h: "IN (foo, bar)", 159 | }; 160 | 161 | it("builds a condition expression", () => { 162 | const actual = buildConditionExpression({ Condition }); 163 | 164 | const expected = [ 165 | "#na0f0d7ff = :v5f0025bb", 166 | "#ne4645342 > :vc823bd86", 167 | "#n54601b21 >= :vaeeabc63", 168 | "#nae599c14 < :vf13631fc", 169 | "#n7c866780 <= :vdd20580d", 170 | "#n79761749 <> :v77e3e295", 171 | "#n42f580fe between :vde135ba3 and :v11392247", 172 | "#ne38a286c in (:v5f0025bb,:v22f4f0ae)", 173 | ] 174 | .map((exp) => `(${exp})`) 175 | .join(" AND "); 176 | expect(actual).toStrictEqual(expected); 177 | }); 178 | 179 | it("builds a condition expression with a specific logical operator", () => { 180 | const actual = buildConditionExpression({ 181 | Condition, 182 | LogicalOperator: "OR", 183 | }); 184 | 185 | const expected = [ 186 | "#na0f0d7ff = :v5f0025bb", 187 | "#ne4645342 > :vc823bd86", 188 | "#n54601b21 >= :vaeeabc63", 189 | "#nae599c14 < :vf13631fc", 190 | "#n7c866780 <= :vdd20580d", 191 | "#n79761749 <> :v77e3e295", 192 | "#n42f580fe between :vde135ba3 and :v11392247", 193 | "#ne38a286c in (:v5f0025bb,:v22f4f0ae)", 194 | ] 195 | .map((exp) => `(${exp})`) 196 | .join(" OR "); 197 | expect(actual).toStrictEqual(expected); 198 | }); 199 | 200 | it("builds a condition expression with a list of expressions for the same field", () => { 201 | const actual = buildConditionExpression({ 202 | Condition: { 203 | a: [ 204 | "foo", 205 | "> 1", 206 | ">= 2", 207 | "< 3", 208 | "<= 4", 209 | "<> 5", 210 | "BETWEEN 6 AND 7", 211 | "IN (foo, bar)", 212 | ], 213 | b: "bar", 214 | }, 215 | LogicalOperator: "OR", 216 | }); 217 | 218 | const expected = [ 219 | "#na0f0d7ff = :v5f0025bb", 220 | "#na0f0d7ff > :vc823bd86", 221 | "#na0f0d7ff >= :vaeeabc63", 222 | "#na0f0d7ff < :vf13631fc", 223 | "#na0f0d7ff <= :vdd20580d", 224 | "#na0f0d7ff <> :v77e3e295", 225 | "#na0f0d7ff between :vde135ba3 and :v11392247", 226 | "#na0f0d7ff in (:v5f0025bb,:v22f4f0ae)", 227 | "#ne4645342 = :v22f4f0ae", 228 | ] 229 | .map((exp) => `(${exp})`) 230 | .join(" OR "); 231 | expect(actual).toStrictEqual(expected); 232 | }); 233 | 234 | it("builds the ExpressionAttributeNameMap", () => { 235 | const actual = buildConditionAttributeNames(Condition); 236 | 237 | const expected = { 238 | "#na0f0d7ff": "a", 239 | "#ne4645342": "b", 240 | "#n54601b21": "c", 241 | "#nae599c14": "d", 242 | "#n7c866780": "e", 243 | "#n79761749": "f", 244 | "#n42f580fe": "g", 245 | "#ne38a286c": "h", 246 | }; 247 | expect(actual).toStrictEqual(expected); 248 | }); 249 | 250 | it("builds the ExpressionAttributeNameMap with an existing map", () => { 251 | const Condition2 = { b: "foo" }; 252 | const params: IConditionAttributeNamesParams = { 253 | ExpressionAttributeNames: { "#a": "a" }, 254 | }; 255 | const actual = buildConditionAttributeNames(Condition2, params); 256 | 257 | const expected = { 258 | "#a": "a", 259 | "#ne4645342": "b", 260 | }; 261 | expect(actual).toStrictEqual(expected); 262 | }); 263 | 264 | it("builds the ExpressionAttributesValueMap", () => { 265 | const actual = buildConditionAttributeValues(Condition); 266 | 267 | const expected = { 268 | ":v11392247": 7, 269 | ":v22f4f0ae": "bar", 270 | ":vc823bd86": 1, 271 | ":v77e3e295": 5, 272 | ":vdd20580d": 4, 273 | ":vde135ba3": 6, 274 | ":vaeeabc63": 2, 275 | ":v5f0025bb": "foo", 276 | ":vf13631fc": 3, 277 | }; 278 | expect(actual).toStrictEqual(expected); 279 | }); 280 | 281 | it("builds the attribute names map with composite keys", () => { 282 | const Condition2 = { 'object."key.with-chars".value': "> 2" }; 283 | const actual = buildConditionAttributeNames(Condition2); 284 | 285 | const expected = { 286 | "#n10d6f4c5": "value", 287 | "#nbb017076": "object", 288 | "#n0327a04a": "key.with-chars", 289 | }; 290 | expect(actual).toStrictEqual(expected); 291 | }); 292 | 293 | it("builds the ExpressionAttributesValueMap with an existing map", () => { 294 | const Condition2 = { b: "foo" }; 295 | const args: IConditionAttributeValuesParams = { 296 | ExpressionAttributeValues: { ":a": "bar" }, 297 | }; 298 | const actual = buildConditionAttributeValues(Condition2, args); 299 | 300 | const expected = { 301 | ":a": "bar", 302 | ":v5f0025bb": "foo", 303 | }; 304 | expect(actual).toStrictEqual(expected); 305 | }); 306 | 307 | it("builds the ExpressionAttributesValueMap with multiple expressions for the same field", () => { 308 | const Condition2 = { b: ["foo", "attribute_exists"] }; 309 | const result = buildConditionAttributeValues(Condition2); 310 | 311 | const expected = { ":v5f0025bb": "foo" }; 312 | expect(result).toStrictEqual(expected); 313 | }); 314 | }); 315 | 316 | describe("functions", () => { 317 | const Condition = { 318 | a: "attribute_exists", 319 | b: "attribute_not_exists", 320 | c: "attribute_type(S)", 321 | d: "begins_with(foo)", 322 | e: "contains(foo)", 323 | f: "size > 10", 324 | g: "attribute_exists ", 325 | h: " attribute_not_exists ", 326 | }; 327 | 328 | it("builds a condition expression", () => { 329 | const actual = buildConditionExpression({ Condition }); 330 | 331 | const expected = [ 332 | "attribute_exists(#na0f0d7ff)", 333 | "attribute_not_exists(#ne4645342)", 334 | "attribute_type(#n54601b21,:va6a17c2f)", 335 | "begins_with(#nae599c14,:v5f0025bb)", 336 | "contains(#n7c866780,:v5f0025bb)", 337 | "size(#n79761749) > :va8d1f941", 338 | "attribute_exists(#n42f580fe)", 339 | "attribute_not_exists(#ne38a286c)", 340 | ] 341 | .map((exp) => `(${exp})`) 342 | .join(" AND "); 343 | expect(actual).toStrictEqual(expected); 344 | }); 345 | 346 | it("builds the ExpressionAttributeNameMap", () => { 347 | const actual = buildConditionAttributeNames(Condition); 348 | 349 | const expected = { 350 | "#na0f0d7ff": "a", 351 | "#ne4645342": "b", 352 | "#ne38a286c": "h", 353 | "#n54601b21": "c", 354 | "#n42f580fe": "g", 355 | "#nae599c14": "d", 356 | "#n7c866780": "e", 357 | "#n79761749": "f", 358 | }; 359 | expect(actual).toStrictEqual(expected); 360 | }); 361 | 362 | it("builds the ExpressionAttributesValueMap", () => { 363 | const actual = buildConditionAttributeValues(Condition); 364 | 365 | const expected = { 366 | ":va6a17c2f": "S", 367 | ":v5f0025bb": "foo", 368 | ":va8d1f941": 10, 369 | }; 370 | expect(actual).toStrictEqual(expected); 371 | }); 372 | }); 373 | 374 | it("handles comparators look-a-likes", () => { 375 | const Condition = { 376 | a: "attribute_type_number", 377 | b: "begins_without", 378 | c: "inspector", 379 | d: "sizeable", 380 | e: "contains sugar", 381 | f: "attribute_exists_there", 382 | g: "attribute_not_exists_here", 383 | }; 384 | const actual = { 385 | Expression: buildConditionExpression({ Condition }), 386 | ExpressionAttributeNames: buildConditionAttributeNames(Condition), 387 | ExpressionAttributeValues: buildConditionAttributeValues(Condition), 388 | }; 389 | 390 | const expected = { 391 | Expression: [ 392 | "#na0f0d7ff = :vc808d243", 393 | "#ne4645342 = :v87d9643e", 394 | "#n54601b21 = :vabb6174f", 395 | "#nae599c14 = :v1a831753", 396 | "#n7c866780 = :v41393c38", 397 | "#n79761749 = :va76dd02b", 398 | "#n42f580fe = :v700afe17", 399 | ] 400 | .map((exp) => `(${exp})`) 401 | .join(" AND "), 402 | ExpressionAttributeNames: { 403 | "#na0f0d7ff": "a", 404 | "#ne4645342": "b", 405 | "#n54601b21": "c", 406 | "#nae599c14": "d", 407 | "#n42f580fe": "g", 408 | "#n79761749": "f", 409 | "#n7c866780": "e", 410 | }, 411 | ExpressionAttributeValues: { 412 | ":v700afe17": "attribute_not_exists_here", 413 | ":vc808d243": "attribute_type_number", 414 | ":v41393c38": "contains sugar", 415 | ":vabb6174f": "inspector", 416 | ":va76dd02b": "attribute_exists_there", 417 | ":v87d9643e": "begins_without", 418 | ":v1a831753": "sizeable", 419 | }, 420 | }; 421 | expect(actual).toStrictEqual(expected); 422 | }); 423 | }); 424 | -------------------------------------------------------------------------------- /src/expressions/helpers.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | IDynamoDbValue, 3 | IDynoexprInputValue, 4 | ILogicalOperatorType, 5 | } from "src/dynoexpr.d"; 6 | 7 | import { 8 | getAttrName, 9 | getAttrValue, 10 | getSingleAttrName, 11 | splitByDot, 12 | } from "../utils"; 13 | 14 | type IValue = string | boolean | number | null; 15 | export function convertValue(value: string): IValue { 16 | const v = value.trim(); 17 | if (v === "null") return null; 18 | if (/^true$|^false$/i.test(v)) return v === "true"; 19 | if (/^[0-9.]+$/.test(v)) return Number(v); 20 | return v; 21 | } 22 | 23 | const REGEX_NOT = /^not\s(.+)/i; 24 | export function parseNotCondition(exp: string) { 25 | const [, v] = REGEX_NOT.exec(exp) || []; 26 | return v.trim(); 27 | } 28 | 29 | const REGEX_ATTRIBUTE_TYPE = /^attribute_type\s*\(([^)]+)/i; 30 | export function parseAttributeTypeValue(exp: string) { 31 | const [, v] = REGEX_ATTRIBUTE_TYPE.exec(exp) || []; 32 | return convertValue(v); 33 | } 34 | 35 | const REGEX_BEGINS_WITH = /^begins_with[ |(]+([^)]+)/i; 36 | export function parseBeginsWithValue(exp: string) { 37 | const [, v] = REGEX_BEGINS_WITH.exec(exp) || []; 38 | return convertValue(v); 39 | } 40 | 41 | const REGEX_BETWEEN = /^between\s+(.+)\s+and\s+(.+)/i; 42 | export function parseBetweenValue(exp: string) { 43 | const vs = REGEX_BETWEEN.exec(exp) || []; 44 | return vs.slice(1, 3).map(convertValue); 45 | } 46 | 47 | const REGEX_COMPARISON = /^[>=<]+\s*(.+)/; 48 | export function parseComparisonValue(exp: string) { 49 | const [, v] = REGEX_COMPARISON.exec(exp) || []; 50 | const sv = v.trim(); 51 | return convertValue(sv); 52 | } 53 | 54 | const REGEX_PARSE_IN = /^in\s*\(([^)]+)/i; 55 | type ParseInValueFn = (exp: string) => IValue[]; 56 | export const parseInValue: ParseInValueFn = (exp) => { 57 | const [, list] = REGEX_PARSE_IN.exec(exp) || []; 58 | return list.split(",").map(convertValue); 59 | }; 60 | 61 | const REGEX_SIZE = /^size\s*[<=>]+\s*(\d+)/i; 62 | export function parseSizeValue(exp: string) { 63 | const [, v] = REGEX_SIZE.exec(exp) || []; 64 | return convertValue(v); 65 | } 66 | 67 | const REGEX_CONTAINS = /^contains\s*\(([^)]+)\)/i; 68 | export function parseContainsValue(exp: string) { 69 | const [, v] = REGEX_CONTAINS.exec(exp) || []; 70 | return convertValue(v); 71 | } 72 | 73 | const REGEX_ATTRIBUTE_EXISTS = /^attribute_exists$/i; 74 | const REGEX_ATTRIBUTE_NOT_EXISTS = /^attribute_not_exists$/i; 75 | 76 | export function flattenExpressions( 77 | Condition: Record 78 | ) { 79 | return Object.entries(Condition).flatMap(([key, value]) => { 80 | if (Array.isArray(value)) { 81 | return value.map((v: IDynoexprInputValue) => [key, v]); 82 | } 83 | 84 | return [[key, value]]; 85 | }) as [string, IDynoexprInputValue][]; 86 | } 87 | 88 | interface IBuildConditionExpressionArgs { 89 | Condition: Record; 90 | LogicalOperator?: ILogicalOperatorType; 91 | } 92 | 93 | export function buildConditionExpression(args: IBuildConditionExpressionArgs) { 94 | const { Condition = {}, LogicalOperator = "AND" } = args; 95 | 96 | return flattenExpressions(Condition) 97 | .map(([key, value]) => { 98 | let expr: string; 99 | if (typeof value === "string") { 100 | let strValue = value.trim(); 101 | 102 | const hasNotCondition = REGEX_NOT.test(strValue); 103 | if (hasNotCondition) { 104 | strValue = parseNotCondition(strValue); 105 | } 106 | 107 | if (REGEX_COMPARISON.test(strValue)) { 108 | const [, operator] = /([<=>]+)/.exec(strValue) || []; 109 | const v = parseComparisonValue(strValue); 110 | expr = `${getAttrName(key)} ${operator} ${getAttrValue(v)}`; 111 | } else if (REGEX_BETWEEN.test(strValue)) { 112 | const v = parseBetweenValue(strValue); 113 | const exp = `between ${getAttrValue(v[0])} and ${getAttrValue(v[1])}`; 114 | expr = `${getAttrName(key)} ${exp}`; 115 | } else if (REGEX_PARSE_IN.test(strValue)) { 116 | const v = parseInValue(strValue); 117 | expr = `${getAttrName(key)} in (${v.map(getAttrValue).join(",")})`; 118 | } else if (REGEX_ATTRIBUTE_EXISTS.test(strValue)) { 119 | expr = `attribute_exists(${getAttrName(key)})`; 120 | } else if (REGEX_ATTRIBUTE_NOT_EXISTS.test(strValue)) { 121 | expr = `attribute_not_exists(${getAttrName(key)})`; 122 | } else if (REGEX_ATTRIBUTE_TYPE.test(strValue)) { 123 | const v = parseAttributeTypeValue(strValue); 124 | expr = `attribute_type(${getAttrName(key)},${getAttrValue(v)})`; 125 | } else if (REGEX_BEGINS_WITH.test(strValue)) { 126 | const v = parseBeginsWithValue(strValue); 127 | expr = `begins_with(${getAttrName(key)},${getAttrValue(v)})`; 128 | } else if (REGEX_CONTAINS.test(strValue)) { 129 | const v = parseContainsValue(strValue); 130 | expr = `contains(${getAttrName(key)},${getAttrValue(v)})`; 131 | } else if (REGEX_SIZE.test(strValue)) { 132 | const [, operator] = /([<=>]+)/.exec(strValue) || []; 133 | const v = parseSizeValue(strValue); 134 | expr = `size(${getAttrName(key)}) ${operator} ${getAttrValue(v)}`; 135 | } else { 136 | expr = `${getAttrName(key)} = ${getAttrValue(strValue)}`; 137 | } 138 | 139 | // adds NOT condition if it exists 140 | expr = [hasNotCondition && "not", expr].filter(Boolean).join(" "); 141 | } else { 142 | expr = `${getAttrName(key)} = ${getAttrValue(value)}`; 143 | } 144 | 145 | return expr; 146 | }) 147 | .map((expr) => `(${expr})`) 148 | .join(` ${LogicalOperator} `); 149 | } 150 | 151 | export interface IConditionAttributeNamesParams { 152 | ExpressionAttributeNames?: { [key: string]: string }; 153 | } 154 | 155 | export function buildConditionAttributeNames( 156 | condition: Record, 157 | params: IConditionAttributeNamesParams = {} 158 | ) { 159 | return Object.keys(condition).reduce( 160 | (acc, key) => { 161 | splitByDot(key).forEach((k) => { 162 | acc[getSingleAttrName(k)] = k; 163 | }); 164 | return acc; 165 | }, 166 | params.ExpressionAttributeNames || ({} as { [key: string]: string }) 167 | ); 168 | } 169 | 170 | export interface IConditionAttributeValuesParams { 171 | ExpressionAttributeValues?: { [key: string]: IDynamoDbValue }; 172 | } 173 | 174 | export function buildConditionAttributeValues( 175 | condition: Record, 176 | params: IConditionAttributeValuesParams = {} 177 | ) { 178 | return flattenExpressions(condition).reduce( 179 | (acc, [, value]) => { 180 | let v: IDynamoDbValue | undefined; 181 | if (typeof value === "string") { 182 | let strValue = value.trim(); 183 | 184 | const hasNotCondition = REGEX_NOT.test(strValue); 185 | if (hasNotCondition) { 186 | strValue = parseNotCondition(strValue); 187 | } 188 | 189 | if (REGEX_COMPARISON.test(strValue)) { 190 | v = parseComparisonValue(strValue); 191 | } else if (REGEX_BETWEEN.test(strValue)) { 192 | v = parseBetweenValue(strValue); 193 | } else if (REGEX_PARSE_IN.test(strValue)) { 194 | v = parseInValue(strValue); 195 | } else if (REGEX_ATTRIBUTE_TYPE.test(strValue)) { 196 | v = parseAttributeTypeValue(strValue); 197 | } else if (REGEX_BEGINS_WITH.test(strValue)) { 198 | v = parseBeginsWithValue(strValue); 199 | } else if (REGEX_CONTAINS.test(strValue)) { 200 | v = parseContainsValue(strValue); 201 | } else if (REGEX_SIZE.test(strValue)) { 202 | v = parseSizeValue(strValue); 203 | } else if ( 204 | !REGEX_ATTRIBUTE_EXISTS.test(strValue) && 205 | !REGEX_ATTRIBUTE_NOT_EXISTS.test(strValue) 206 | ) { 207 | v = strValue; 208 | } 209 | } else { 210 | v = value; 211 | } 212 | 213 | if (typeof v === "undefined") { 214 | return acc; 215 | } 216 | 217 | if (Array.isArray(v)) { 218 | v.forEach((val) => { 219 | acc[getAttrValue(val)] = val; 220 | }); 221 | } else { 222 | acc[getAttrValue(v)] = v; 223 | } 224 | 225 | return acc; 226 | }, 227 | params.ExpressionAttributeValues || 228 | ({} as { [key: string]: IDynamoDbValue }) 229 | ); 230 | } 231 | -------------------------------------------------------------------------------- /src/expressions/key-condition.test.ts: -------------------------------------------------------------------------------- 1 | import type { IKeyConditionInput } from "src/dynoexpr.d"; 2 | 3 | import { getKeyConditionExpression } from "./key-condition"; 4 | 5 | describe("key condition expression", () => { 6 | it("builds the ConditionExpression and NameValueMaps - comparison operators", () => { 7 | const KeyCondition = { 8 | a: "foo", 9 | b: "> 1", 10 | c: ">= 2", 11 | d: "< 3", 12 | e: "<= 4", 13 | f: "<> 5", 14 | fa: "<> true", 15 | g: "BETWEEN 6 AND 7", 16 | h: "IN (foo, bar)", 17 | }; 18 | const args: IKeyConditionInput = { KeyCondition }; 19 | const actual = getKeyConditionExpression(args); 20 | 21 | const expected = { 22 | KeyConditionExpression: [ 23 | "#na0f0d7ff = :v5f0025bb", 24 | "#ne4645342 > :vc823bd86", 25 | "#n54601b21 >= :vaeeabc63", 26 | "#nae599c14 < :vf13631fc", 27 | "#n7c866780 <= :vdd20580d", 28 | "#n79761749 <> :v77e3e295", 29 | "#n14e68f2d <> :vc86ac629", 30 | "#n42f580fe between :vde135ba3 and :v11392247", 31 | "#ne38a286c in (:v5f0025bb,:v22f4f0ae)", 32 | ] 33 | .map((exp) => `(${exp})`) 34 | .join(" AND "), 35 | ExpressionAttributeNames: { 36 | "#na0f0d7ff": "a", 37 | "#ne4645342": "b", 38 | "#n54601b21": "c", 39 | "#nae599c14": "d", 40 | "#n7c866780": "e", 41 | "#n79761749": "f", 42 | "#n14e68f2d": "fa", 43 | "#n42f580fe": "g", 44 | "#ne38a286c": "h", 45 | }, 46 | ExpressionAttributeValues: { 47 | ":v11392247": 7, 48 | ":v22f4f0ae": "bar", 49 | ":vc823bd86": 1, 50 | ":v77e3e295": 5, 51 | ":vc86ac629": true, 52 | ":vdd20580d": 4, 53 | ":vde135ba3": 6, 54 | ":vaeeabc63": 2, 55 | ":v5f0025bb": "foo", 56 | ":vf13631fc": 3, 57 | }, 58 | }; 59 | expect(actual).toStrictEqual(expected); 60 | }); 61 | 62 | it("builds the ConditionExpression and NameValueMaps - function", () => { 63 | const KeyCondition = { 64 | a: "attribute_exists", 65 | b: "attribute_not_exists", 66 | c: "attribute_type(S)", 67 | d: "begins_with(foo)", 68 | e: "contains(foo)", 69 | f: "size > 10", 70 | }; 71 | const args: IKeyConditionInput = { KeyCondition }; 72 | const actual = getKeyConditionExpression(args); 73 | 74 | const expected = { 75 | KeyConditionExpression: [ 76 | "attribute_exists(#na0f0d7ff)", 77 | "attribute_not_exists(#ne4645342)", 78 | "attribute_type(#n54601b21,:va6a17c2f)", 79 | "begins_with(#nae599c14,:v5f0025bb)", 80 | "contains(#n7c866780,:v5f0025bb)", 81 | "size(#n79761749) > :va8d1f941", 82 | ] 83 | .map((exp) => `(${exp})`) 84 | .join(" AND "), 85 | ExpressionAttributeNames: { 86 | "#nae599c14": "d", 87 | "#n79761749": "f", 88 | "#n54601b21": "c", 89 | "#na0f0d7ff": "a", 90 | "#ne4645342": "b", 91 | "#n7c866780": "e", 92 | }, 93 | ExpressionAttributeValues: { 94 | ":va6a17c2f": "S", 95 | ":v5f0025bb": "foo", 96 | ":va8d1f941": 10, 97 | }, 98 | }; 99 | expect(actual).toStrictEqual(expected); 100 | }); 101 | 102 | it("builds the ConditionExpression and NameValueMaps - mixed operators", () => { 103 | const KeyCondition = { 104 | a: 1, 105 | b: "between 2 and 3", 106 | c: "size > 4", 107 | }; 108 | const args: IKeyConditionInput = { KeyCondition }; 109 | const actual = getKeyConditionExpression(args); 110 | 111 | const expected = { 112 | KeyConditionExpression: [ 113 | "#na0f0d7ff = :vc823bd86", 114 | "#ne4645342 between :vaeeabc63 and :vf13631fc", 115 | "size(#n54601b21) > :vdd20580d", 116 | ] 117 | .map((exp) => `(${exp})`) 118 | .join(" AND "), 119 | ExpressionAttributeNames: { 120 | "#na0f0d7ff": "a", 121 | "#ne4645342": "b", 122 | "#n54601b21": "c", 123 | }, 124 | ExpressionAttributeValues: { 125 | ":vc823bd86": 1, 126 | ":vaeeabc63": 2, 127 | ":vf13631fc": 3, 128 | ":vdd20580d": 4, 129 | }, 130 | }; 131 | expect(actual).toStrictEqual(expected); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /src/expressions/key-condition.ts: -------------------------------------------------------------------------------- 1 | import type { IKeyConditionInput } from "src/dynoexpr.d"; 2 | 3 | import { 4 | buildConditionAttributeNames, 5 | buildConditionAttributeValues, 6 | buildConditionExpression, 7 | } from "./helpers"; 8 | 9 | export function getKeyConditionExpression(params: IKeyConditionInput = {}) { 10 | if (!params.KeyCondition) { 11 | return params; 12 | } 13 | 14 | const { KeyCondition, KeyConditionLogicalOperator, ...restOfParams } = params; 15 | 16 | const KeyConditionExpression = buildConditionExpression({ 17 | Condition: KeyCondition, 18 | LogicalOperator: KeyConditionLogicalOperator, 19 | }); 20 | 21 | const ExpressionAttributeNames = buildConditionAttributeNames( 22 | KeyCondition, 23 | params 24 | ); 25 | 26 | const ExpressionAttributeValues = buildConditionAttributeValues( 27 | KeyCondition, 28 | params 29 | ); 30 | 31 | return { 32 | ...restOfParams, 33 | KeyConditionExpression, 34 | ExpressionAttributeNames, 35 | ExpressionAttributeValues, 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /src/expressions/projection.test.ts: -------------------------------------------------------------------------------- 1 | import type { IProjectionInput } from "src/dynoexpr.d"; 2 | 3 | import { getProjectionExpression } from "./projection"; 4 | 5 | describe("projection expression", () => { 6 | it("converts a ProjectionExpression to ExpressionAttributesMap", () => { 7 | const args: IProjectionInput = { 8 | Projection: ["foo", "cast", "year", "baz"], 9 | }; 10 | const actual = getProjectionExpression(args); 11 | 12 | const expected = { 13 | ProjectionExpression: "#n5f0025bb,#n66d7cb7d,#n645820bf,#n82504b33", 14 | ExpressionAttributeNames: { 15 | "#n645820bf": "year", 16 | "#n82504b33": "baz", 17 | "#n5f0025bb": "foo", 18 | "#n66d7cb7d": "cast", 19 | }, 20 | }; 21 | expect(actual).toStrictEqual(expected); 22 | }); 23 | 24 | it("adds new names to an existing ExpressionAttributesMap", () => { 25 | const args: IProjectionInput = { 26 | Projection: ["foo", "cast", "year", "baz"], 27 | ExpressionAttributeNames: { 28 | "#quz": "quz", 29 | }, 30 | }; 31 | const actual = getProjectionExpression(args); 32 | 33 | const expected = { 34 | ProjectionExpression: "#n5f0025bb,#n66d7cb7d,#n645820bf,#n82504b33", 35 | ExpressionAttributeNames: { 36 | "#quz": "quz", 37 | "#n645820bf": "year", 38 | "#n82504b33": "baz", 39 | "#n5f0025bb": "foo", 40 | "#n66d7cb7d": "cast", 41 | }, 42 | }; 43 | expect(actual).toStrictEqual(expected); 44 | }); 45 | 46 | it("maintains existing ProjectionExpression names", () => { 47 | const args: IProjectionInput = { 48 | Projection: ["foo", "baz"], 49 | ExpressionAttributeNames: { 50 | "#foo": "foo", 51 | }, 52 | }; 53 | const actual = getProjectionExpression(args); 54 | 55 | const expected = { 56 | ProjectionExpression: "#n5f0025bb,#n82504b33", 57 | ExpressionAttributeNames: { 58 | "#foo": "foo", 59 | "#n5f0025bb": "foo", 60 | "#n82504b33": "baz", 61 | }, 62 | }; 63 | expect(actual).toStrictEqual(expected); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/expressions/projection.ts: -------------------------------------------------------------------------------- 1 | import type { IProjectionInput } from "src/dynoexpr.d"; 2 | 3 | import { getAttrName } from "../utils"; 4 | 5 | export function getProjectionExpression(params: IProjectionInput = {}) { 6 | if (!params.Projection) { 7 | return params; 8 | } 9 | 10 | const { Projection, ...restOfParams } = params; 11 | 12 | const fields = Projection.map((field) => field.trim()); 13 | 14 | const ProjectionExpression = fields.map(getAttrName).join(","); 15 | 16 | const ExpressionAttributeNames = fields.reduce((acc, field) => { 17 | const attrName = getAttrName(field); 18 | if (attrName in acc) return acc; 19 | acc[attrName] = field; 20 | return acc; 21 | }, params.ExpressionAttributeNames || {}); 22 | 23 | return { 24 | ...restOfParams, 25 | ProjectionExpression, 26 | ExpressionAttributeNames, 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /src/expressions/update-ops.test.ts: -------------------------------------------------------------------------------- 1 | import type { IUpdateInput } from "src/dynoexpr.d"; 2 | 3 | import { 4 | getUpdateAddExpression, 5 | getUpdateDeleteExpression, 6 | getUpdateOperationsExpression, 7 | getUpdateRemoveExpression, 8 | getUpdateSetExpression, 9 | } from "./update-ops"; 10 | 11 | describe("update operations - SET/REMOVE/ADD/DELETE", () => { 12 | it("builds a SET update expression", () => { 13 | const args: IUpdateInput = { 14 | UpdateSet: { 15 | foo: "foo - 2", 16 | bar: "2 - bar", 17 | baz: "baz + 9", 18 | bez: [1, 2, 3], 19 | buz: { biz: 3 }, 20 | boz: [{ qux: 2 }], 21 | biz: null, 22 | }, 23 | }; 24 | const actual = getUpdateSetExpression(args); 25 | 26 | const expected = { 27 | UpdateExpression: 28 | "SET #n5f0025bb = #n5f0025bb - :vaeeabc63, #n22f4f0ae = :vaeeabc63 - #n22f4f0ae, #n82504b33 = #n82504b33 + :vf489a8ba, #ne4642e6a = :v761dc2b7, #nadc27efb = :v81f92362, #n5fae6dd3 = :v50ed5650, #n025c5f64 = :v89dff0bd", 29 | ExpressionAttributeNames: { 30 | "#n5f0025bb": "foo", 31 | "#n22f4f0ae": "bar", 32 | "#n82504b33": "baz", 33 | "#n025c5f64": "biz", 34 | "#ne4642e6a": "bez", 35 | "#nadc27efb": "buz", 36 | "#n5fae6dd3": "boz", 37 | }, 38 | ExpressionAttributeValues: { 39 | ":vaeeabc63": 2, 40 | ":vf489a8ba": 9, 41 | ":v81f92362": { biz: 3 }, 42 | ":v50ed5650": [{ qux: 2 }], 43 | ":v761dc2b7": [1, 2, 3], 44 | ":v89dff0bd": null, 45 | }, 46 | }; 47 | expect(actual).toStrictEqual(expected); 48 | }); 49 | 50 | it("builds a REMOVE update expression", () => { 51 | const args = { 52 | UpdateRemove: { 53 | foo: "bar", 54 | baz: 2, 55 | }, 56 | }; 57 | const actual = getUpdateRemoveExpression(args); 58 | 59 | const expected = { 60 | UpdateExpression: "REMOVE #n5f0025bb, #n82504b33", 61 | ExpressionAttributeNames: { 62 | "#n5f0025bb": "foo", 63 | "#n82504b33": "baz", 64 | }, 65 | ExpressionAttributeValues: {}, 66 | }; 67 | expect(actual).toStrictEqual(expected); 68 | }); 69 | 70 | it("builds an ADD update expression", () => { 71 | const args = { 72 | UpdateAdd: { 73 | foo: "bar", 74 | baz: 2, 75 | bez: [1, 2, 3], 76 | buz: { biz: 3 }, 77 | boz: [{ qux: 2 }], 78 | }, 79 | }; 80 | const actual = getUpdateAddExpression(args); 81 | 82 | const expected = { 83 | UpdateExpression: 84 | "ADD #n5f0025bb :v22f4f0ae, #n82504b33 :vaeeabc63, #ne4642e6a :v5b66646d, #nadc27efb :v81f92362, #n5fae6dd3 :v533877e7", 85 | ExpressionAttributeNames: { 86 | "#n5f0025bb": "foo", 87 | "#n82504b33": "baz", 88 | "#nadc27efb": "buz", 89 | "#ne4642e6a": "bez", 90 | "#n5fae6dd3": "boz", 91 | }, 92 | ExpressionAttributeValues: { 93 | ":v22f4f0ae": "bar", 94 | ":vaeeabc63": 2, 95 | ":v81f92362": { biz: 3 }, 96 | ":v533877e7": new Set([{ qux: 2 }]), 97 | ":v5b66646d": new Set([1, 2, 3]), 98 | }, 99 | }; 100 | expect(actual).toStrictEqual(expected); 101 | }); 102 | 103 | it("builds a DELETE update expression", () => { 104 | const args = { 105 | UpdateDelete: { 106 | foo: "bar", 107 | baz: 2, 108 | bez: [1, 2, 3], 109 | buz: { biz: 3 }, 110 | boz: [{ qux: 2 }], 111 | }, 112 | }; 113 | const actual = getUpdateDeleteExpression(args); 114 | 115 | const expected = { 116 | UpdateExpression: 117 | "DELETE #n5f0025bb :v22f4f0ae, #n82504b33 :vaeeabc63, #ne4642e6a :v5b66646d, #nadc27efb :v81f92362, #n5fae6dd3 :v533877e7", 118 | ExpressionAttributeNames: { 119 | "#nadc27efb": "buz", 120 | "#n82504b33": "baz", 121 | "#ne4642e6a": "bez", 122 | "#n5f0025bb": "foo", 123 | "#n5fae6dd3": "boz", 124 | }, 125 | ExpressionAttributeValues: { 126 | ":v81f92362": { biz: 3 }, 127 | ":v22f4f0ae": "bar", 128 | ":vaeeabc63": 2, 129 | ":v533877e7": new Set([{ qux: 2 }]), 130 | ":v5b66646d": new Set([1, 2, 3]), 131 | }, 132 | }; 133 | expect(actual).toStrictEqual(expected); 134 | }); 135 | 136 | it("builds multiple update expressions", () => { 137 | const args = { 138 | UpdateSet: { ufoo: "ufoo - 2" }, 139 | UpdateRemove: { rfoo: "rbar" }, 140 | UpdateAdd: { afoo: "abar" }, 141 | UpdateDelete: { dfoo: "dbar" }, 142 | }; 143 | const actual = getUpdateOperationsExpression(args); 144 | 145 | const expected = { 146 | UpdateExpression: 147 | "SET #n30eb7c82 = #n30eb7c82 - :vaeeabc63 REMOVE #na6e432d1 ADD #n7cb54de8 :vca09015b DELETE #nca5c700c :v9b57e285", 148 | ExpressionAttributeNames: { 149 | "#nca5c700c": "dfoo", 150 | "#n30eb7c82": "ufoo", 151 | "#na6e432d1": "rfoo", 152 | "#n7cb54de8": "afoo", 153 | }, 154 | ExpressionAttributeValues: { 155 | ":vca09015b": "abar", 156 | ":vaeeabc63": 2, 157 | ":v9b57e285": "dbar", 158 | }, 159 | }; 160 | expect(actual).toStrictEqual(expected); 161 | }); 162 | }); 163 | -------------------------------------------------------------------------------- /src/expressions/update-ops.ts: -------------------------------------------------------------------------------- 1 | import type { IUpdateInput, IUpdateOutput } from "src/dynoexpr.d"; 2 | 3 | import { getUpdateExpression } from "./update"; 4 | 5 | export function getUpdateSetExpression(params?: IUpdateInput) { 6 | const { UpdateSet, ...restOfParams } = params || {}; 7 | 8 | return getUpdateExpression({ 9 | ...restOfParams, 10 | Update: UpdateSet, 11 | UpdateAction: "SET", 12 | }); 13 | } 14 | 15 | export function getUpdateRemoveExpression(params?: IUpdateInput) { 16 | const { UpdateRemove, ...restOfParams } = params || {}; 17 | 18 | return getUpdateExpression({ 19 | ...restOfParams, 20 | Update: UpdateRemove, 21 | UpdateAction: "REMOVE", 22 | }); 23 | } 24 | 25 | export function getUpdateAddExpression(params?: IUpdateInput) { 26 | const { UpdateAdd, ...restOfParams } = params || {}; 27 | 28 | return getUpdateExpression({ 29 | ...restOfParams, 30 | Update: UpdateAdd, 31 | UpdateAction: "ADD", 32 | }); 33 | } 34 | 35 | export function getUpdateDeleteExpression(params?: IUpdateInput) { 36 | const { UpdateDelete, ...restOfParams } = params || {}; 37 | 38 | return getUpdateExpression({ 39 | ...restOfParams, 40 | Update: UpdateDelete, 41 | UpdateAction: "DELETE", 42 | }); 43 | } 44 | 45 | export function getUpdateOperationsExpression(params: IUpdateInput = {}) { 46 | const updateExpressions: unknown[] = []; 47 | const outputParams = [ 48 | getUpdateSetExpression, 49 | getUpdateRemoveExpression, 50 | getUpdateAddExpression, 51 | getUpdateDeleteExpression, 52 | ].reduce((acc, getExpressionFn) => { 53 | const expr = getExpressionFn(acc); 54 | const { UpdateExpression = "" } = expr; 55 | updateExpressions.push(UpdateExpression); 56 | return expr; 57 | }, params as IUpdateOutput); 58 | 59 | const aggUpdateExpression = updateExpressions 60 | .filter(Boolean) 61 | .filter((e, i, a) => a.indexOf(e) === i) 62 | .join(" "); 63 | if (aggUpdateExpression) { 64 | outputParams.UpdateExpression = aggUpdateExpression; 65 | } 66 | 67 | return outputParams; 68 | } 69 | -------------------------------------------------------------------------------- /src/expressions/update.test.ts: -------------------------------------------------------------------------------- 1 | import type { IUpdateInput } from "src/dynoexpr.d"; 2 | 3 | import { 4 | getExpressionAttributes, 5 | getUpdateExpression, 6 | isMathExpression, 7 | parseOperationValue, 8 | } from "./update"; 9 | 10 | describe("update expression", () => { 11 | it.each(["foo + 2", "foo - 2", "2 - foo", "2 + foo", "foo + 2", "foo+2"])( 12 | "parses the number on a math operation update: %s", 13 | (expr) => { 14 | const actual = parseOperationValue(expr, "foo"); 15 | expect(actual).toBe(2); 16 | } 17 | ); 18 | 19 | it("converts from an obj to ExpressionAttributes", () => { 20 | const Update = { 21 | foo: "bar", 22 | baz: 2, 23 | "foo-bar": "buz", 24 | fooBar: "buzz", 25 | "foo.bar": "quz", 26 | foo_bar: "qiz", 27 | FooBaz: null, 28 | quz: "if_not_exists(bazz)", 29 | Price: "if_not_exists(:p)", 30 | }; 31 | const args = { Update }; 32 | const actual = getExpressionAttributes(args); 33 | 34 | const expected = { 35 | Update, 36 | ExpressionAttributeNames: { 37 | "#n7b8f2f7a": "Price", 38 | "#n9efa7dcb": "quz", 39 | "#n5f0025bb": "foo", 40 | "#n22f4f0ae": "bar", 41 | "#n82504b33": "baz", 42 | "#n883b58ea": "foo-bar", 43 | "#n8af247c0": "fooBar", 44 | "#n851bf028": "foo_bar", 45 | "#n6dc4982e": "FooBaz", 46 | }, 47 | ExpressionAttributeValues: { 48 | ":v22f4f0ae": "bar", 49 | ":vaeeabc63": 2, 50 | ":vadc27efb": "buz", 51 | ":v626130a1": "buzz", 52 | ":p": ":p", 53 | ":v42fa11db": "bazz", 54 | ":v9efa7dcb": "quz", 55 | ":v89dff0bd": null, 56 | ":ve628f750": "qiz", 57 | }, 58 | }; 59 | expect(actual).toStrictEqual(expected); 60 | }); 61 | 62 | it("builds ExpressionAttributesMap with existing maps", () => { 63 | const Update = { a: 1 }; 64 | const args = { 65 | Update, 66 | ExpressionAttributeNames: { "#b": "b" }, 67 | ExpressionAttributeValues: { ":b": 2 }, 68 | }; 69 | const actual = getExpressionAttributes(args); 70 | 71 | const expected = { 72 | Update, 73 | ExpressionAttributeNames: { "#b": "b", "#na0f0d7ff": "a" }, 74 | ExpressionAttributeValues: { ":b": 2, ":vc823bd86": 1 }, 75 | }; 76 | expect(actual).toStrictEqual(expected); 77 | }); 78 | 79 | it("builds ExpressionAttributesMap with composite keys", () => { 80 | const args = { 81 | Update: { 82 | 'object."key.with-chars".value': 'object."key.with-chars".value + 1', 83 | }, 84 | ConditionExpression: "(#nbb017076.#n0327a04a.#n10d6f4c5 > :vaeeabc63)", 85 | ExpressionAttributeNames: { 86 | "#nbb017076": "object", 87 | "#n0327a04a": "key.with-chars", 88 | "#n10d6f4c5": "value", 89 | }, 90 | ExpressionAttributeValues: { ":vaeeabc63": 2 }, 91 | }; 92 | 93 | const actual = getExpressionAttributes(args); 94 | 95 | const expected = { 96 | ConditionExpression: "(#nbb017076.#n0327a04a.#n10d6f4c5 > :vaeeabc63)", 97 | ExpressionAttributeNames: { 98 | "#n0327a04a": "key.with-chars", 99 | "#n10d6f4c5": "value", 100 | "#nbb017076": "object", 101 | }, 102 | ExpressionAttributeValues: { 103 | ":vaeeabc63": 2, 104 | ":vc823bd86": 1, 105 | }, 106 | Update: { 107 | 'object."key.with-chars".value': 'object."key.with-chars".value + 1', 108 | }, 109 | }; 110 | expect(actual).toStrictEqual(expected); 111 | }); 112 | 113 | it("updates attributes - SET", () => { 114 | const args = { 115 | Update: { 116 | foo: "bar", 117 | baz: 2, 118 | buz: { biz: 3 }, 119 | "foo.bar": 4, 120 | "foo.bar.baz": "buz", 121 | "foo.baz": null, 122 | }, 123 | }; 124 | const actual = getUpdateExpression(args); 125 | 126 | const expected = { 127 | UpdateExpression: 128 | "SET #n5f0025bb = :v22f4f0ae, #n82504b33 = :vaeeabc63, #nadc27efb = :v81f92362, #n5f0025bb.#n22f4f0ae = :vdd20580d, #n5f0025bb.#n22f4f0ae.#n82504b33 = :vadc27efb, #n5f0025bb.#n82504b33 = :v89dff0bd", 129 | ExpressionAttributeNames: { 130 | "#n5f0025bb": "foo", 131 | "#n22f4f0ae": "bar", 132 | "#n82504b33": "baz", 133 | "#nadc27efb": "buz", 134 | }, 135 | ExpressionAttributeValues: { 136 | ":v22f4f0ae": "bar", 137 | ":vaeeabc63": 2, 138 | ":v81f92362": { biz: 3 }, 139 | ":vdd20580d": 4, 140 | ":vadc27efb": "buz", 141 | ":v89dff0bd": null, 142 | }, 143 | }; 144 | expect(actual).toStrictEqual(expected); 145 | }); 146 | 147 | describe("if_not_exists", () => { 148 | it("update expression with if_not_exists", () => { 149 | const args = { 150 | Update: { foo: "if_not_exists(bar)" }, 151 | }; 152 | const actual = getUpdateExpression(args); 153 | 154 | const expected = { 155 | UpdateExpression: 156 | "SET #n5f0025bb = if_not_exists(#n5f0025bb, :v22f4f0ae)", 157 | ExpressionAttributeNames: { "#n5f0025bb": "foo" }, 158 | ExpressionAttributeValues: { ":v22f4f0ae": "bar" }, 159 | }; 160 | expect(actual).toStrictEqual(expected); 161 | }); 162 | }); 163 | 164 | describe("list_append", () => { 165 | it("adds to the beginning of the list (numbers)", () => { 166 | const args = { 167 | Update: { foo: "list_append([1, 2], foo)" }, 168 | }; 169 | const actual = getUpdateExpression(args); 170 | 171 | const expected = { 172 | UpdateExpression: 173 | "SET #n5f0025bb = list_append(:v31e6eb45, #n5f0025bb)", 174 | ExpressionAttributeNames: { "#n5f0025bb": "foo" }, 175 | ExpressionAttributeValues: { ":v31e6eb45": [1, 2] }, 176 | }; 177 | expect(actual).toStrictEqual(expected); 178 | }); 179 | 180 | it("adds to the end of the list (numbers)", () => { 181 | const args = { 182 | Update: { foo: "list_append(foo, [1, 2])" }, 183 | }; 184 | const actual = getUpdateExpression(args); 185 | 186 | const expected = { 187 | UpdateExpression: 188 | "SET #n5f0025bb = list_append(#n5f0025bb, :v31e6eb45)", 189 | ExpressionAttributeNames: { "#n5f0025bb": "foo" }, 190 | ExpressionAttributeValues: { ":v31e6eb45": [1, 2] }, 191 | }; 192 | expect(actual).toStrictEqual(expected); 193 | }); 194 | 195 | it("adds to the beginning of the list (strings)", () => { 196 | const args = { 197 | Update: { foo: 'list_append(["buu", 2], foo)' }, 198 | }; 199 | const actual = getUpdateExpression(args); 200 | 201 | const expected = { 202 | UpdateExpression: 203 | "SET #n5f0025bb = list_append(:vc0126eec, #n5f0025bb)", 204 | ExpressionAttributeNames: { "#n5f0025bb": "foo" }, 205 | ExpressionAttributeValues: { ":vc0126eec": ["buu", 2] }, 206 | }; 207 | expect(actual).toStrictEqual(expected); 208 | }); 209 | 210 | it("adds to the end of the list (string)", () => { 211 | const args = { 212 | Update: { foo: 'list_append(foo, [1, "buu"])' }, 213 | }; 214 | const actual = getUpdateExpression(args); 215 | 216 | const expected = { 217 | UpdateExpression: 218 | "SET #n5f0025bb = list_append(#n5f0025bb, :va25015de)", 219 | ExpressionAttributeNames: { "#n5f0025bb": "foo" }, 220 | ExpressionAttributeValues: { ":va25015de": [1, "buu"] }, 221 | }; 222 | expect(actual).toStrictEqual(expected); 223 | }); 224 | }); 225 | 226 | it.each([ 227 | ["foo", "foo - 2", true], 228 | ["foo", "foo-2", true], 229 | ["foo", "10-20-001", false], 230 | ["foo", "foobar - 2", false], 231 | ["foo", "2-foobar", false], 232 | ["foo", "foo - bar", false], 233 | ["foo", "Mon Jun 01 2020 20:54:50 GMT+0100 (British Summer Time)", false], 234 | ["foo", "foo+bar@baz-buz.com", false], 235 | ["foo", "http://baz-buz.com", false], 236 | ["foo", null, false], 237 | ])( 238 | "identifies an expression as being a math expression", 239 | (expr1, expr2, expected) => { 240 | const actual = isMathExpression(expr1, expr2); 241 | expect(actual).toStrictEqual(expected); 242 | } 243 | ); 244 | 245 | it("updates numeric value math operations - SET", () => { 246 | const args: IUpdateInput = { 247 | Update: { 248 | foo: "foo - 2", 249 | bar: "2 - bar", 250 | baz: "baz + 9", 251 | }, 252 | }; 253 | const actual = getUpdateExpression(args); 254 | 255 | const expected = { 256 | UpdateExpression: 257 | "SET #n5f0025bb = #n5f0025bb - :vaeeabc63, #n22f4f0ae = :vaeeabc63 - #n22f4f0ae, #n82504b33 = #n82504b33 + :vf489a8ba", 258 | ExpressionAttributeNames: { 259 | "#n5f0025bb": "foo", 260 | "#n22f4f0ae": "bar", 261 | "#n82504b33": "baz", 262 | }, 263 | ExpressionAttributeValues: { 264 | ":vaeeabc63": 2, 265 | ":vf489a8ba": 9, 266 | }, 267 | }; 268 | expect(actual).toStrictEqual(expected); 269 | }); 270 | 271 | it("updates expression with -/+ but it's not a math expression", () => { 272 | const args = { 273 | Update: { 274 | foo: "10-20-001", 275 | bar: "2020-06-01T19:53:52.457Z", 276 | baz: "Mon Jun 01 2020 20:54:50 GMT+0100 (British Summer Time)", 277 | buz: "foo+bar@baz-buz.com", 278 | }, 279 | }; 280 | const actual = getUpdateExpression(args); 281 | 282 | const expected = { 283 | UpdateExpression: 284 | "SET #n5f0025bb = :v82c546e8, #n22f4f0ae = :v21debd22, #n82504b33 = :vfe34ce2d, #nadc27efb = :vc048c69d", 285 | ExpressionAttributeNames: { 286 | "#n5f0025bb": "foo", 287 | "#n22f4f0ae": "bar", 288 | "#n82504b33": "baz", 289 | "#nadc27efb": "buz", 290 | }, 291 | ExpressionAttributeValues: { 292 | ":v82c546e8": "10-20-001", 293 | ":v21debd22": "2020-06-01T19:53:52.457Z", 294 | ":vfe34ce2d": "Mon Jun 01 2020 20:54:50 GMT+0100 (British Summer Time)", 295 | ":vc048c69d": "foo+bar@baz-buz.com", 296 | }, 297 | }; 298 | expect(actual).toStrictEqual(expected); 299 | }); 300 | 301 | it("adds a number - ADD", () => { 302 | const args: IUpdateInput = { 303 | UpdateAction: "ADD", 304 | Update: { 305 | foo: 5, 306 | }, 307 | }; 308 | const actual = getUpdateExpression(args); 309 | 310 | const expected = { 311 | UpdateExpression: "ADD #n5f0025bb :v77e3e295", 312 | ExpressionAttributeNames: { 313 | "#n5f0025bb": "foo", 314 | }, 315 | ExpressionAttributeValues: { 316 | ":v77e3e295": 5, 317 | }, 318 | }; 319 | expect(actual).toStrictEqual(expected); 320 | }); 321 | 322 | it("adds elements to a set - SET", () => { 323 | const args: IUpdateInput = { 324 | UpdateAction: "ADD", 325 | Update: { 326 | foo: [1, 2], 327 | bar: ["bar", "baz"], 328 | }, 329 | }; 330 | const actual = getUpdateExpression(args); 331 | 332 | const expected = { 333 | UpdateExpression: "ADD #n5f0025bb :v101cd26b, #n22f4f0ae :vc0d39ad1", 334 | ExpressionAttributeNames: { 335 | "#n22f4f0ae": "bar", 336 | "#n5f0025bb": "foo", 337 | }, 338 | ExpressionAttributeValues: { 339 | ":vc0d39ad1": new Set(["bar", "baz"]), 340 | ":v101cd26b": new Set([1, 2]), 341 | }, 342 | }; 343 | expect(actual).toStrictEqual(expected); 344 | }); 345 | 346 | it("removes element from a set - DELETE", () => { 347 | const args: IUpdateInput = { 348 | UpdateAction: "DELETE", 349 | Update: { 350 | foo: [1, 2], 351 | bar: ["bar", "baz"], 352 | }, 353 | }; 354 | const actual = getUpdateExpression(args); 355 | 356 | const expected = { 357 | UpdateExpression: "DELETE #n5f0025bb :v101cd26b, #n22f4f0ae :vc0d39ad1", 358 | ExpressionAttributeNames: { 359 | "#n22f4f0ae": "bar", 360 | "#n5f0025bb": "foo", 361 | }, 362 | ExpressionAttributeValues: { 363 | ":vc0d39ad1": new Set(["bar", "baz"]), 364 | ":v101cd26b": new Set([1, 2]), 365 | }, 366 | }; 367 | expect(actual).toStrictEqual(expected); 368 | }); 369 | 370 | it("gets update expression with composite keys and math", () => { 371 | const args = { Update: { "foo.bar.baz": "foo.bar.baz + 1" } }; 372 | const actual = getUpdateExpression(args); 373 | 374 | const expected = { 375 | ExpressionAttributeNames: { 376 | "#n22f4f0ae": "bar", 377 | "#n5f0025bb": "foo", 378 | "#n82504b33": "baz", 379 | }, 380 | ExpressionAttributeValues: { 381 | ":vc823bd86": 1, 382 | }, 383 | UpdateExpression: 384 | "SET #n5f0025bb.#n22f4f0ae.#n82504b33 = #n5f0025bb.#n22f4f0ae.#n82504b33 + :vc823bd86", 385 | }; 386 | expect(actual).toStrictEqual(expected); 387 | }); 388 | 389 | it("gets update expression with composite keys (escaped)", () => { 390 | const args = { 391 | Update: { 392 | 'object."key.with-chars".value': 'object."key.with-chars".value + 1', 393 | }, 394 | }; 395 | const actual = getUpdateExpression(args); 396 | 397 | const expected = { 398 | ExpressionAttributeNames: { 399 | "#n0327a04a": "key.with-chars", 400 | "#n10d6f4c5": "value", 401 | "#nbb017076": "object", 402 | }, 403 | ExpressionAttributeValues: { 404 | ":vc823bd86": 1, 405 | }, 406 | UpdateExpression: 407 | "SET #nbb017076.#n0327a04a.#n10d6f4c5 = #nbb017076.#n0327a04a.#n10d6f4c5 + :vc823bd86", 408 | }; 409 | expect(actual).toStrictEqual(expected); 410 | }); 411 | }); 412 | -------------------------------------------------------------------------------- /src/expressions/update.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | IDynoexprInputValue, 3 | IUpdateInput, 4 | IUpdateOutput, 5 | } from "src/dynoexpr.d"; 6 | 7 | import { 8 | getAttrName, 9 | getAttrValue, 10 | getSingleAttrName, 11 | splitByDot, 12 | splitByOperator, 13 | } from "../utils"; 14 | 15 | export function parseOperationValue(expr: string, key: string) { 16 | const v = expr.replace(key, "").replace(/[+-]/, ""); 17 | return Number(v.trim()); 18 | } 19 | 20 | export function isMathExpression(name: string, value: IDynoexprInputValue) { 21 | if (typeof name !== "string") { 22 | return false; 23 | } 24 | 25 | const rgLh = new RegExp(`^${name}\\s*[+-]\\s*\\d+$`); 26 | const rgRh = new RegExp(`^\\d+\\s*[+-]\\s*${name}$`); 27 | 28 | return rgLh.test(`${value}`) || rgRh.test(`${value}`); 29 | } 30 | 31 | function fromStrListToArray(strList: string): IDynoexprInputValue[] { 32 | const [, inner] = /^\[([^\]]+)\]$/.exec(strList) || []; 33 | return inner.split(",").map((v) => JSON.parse(v)); 34 | } 35 | 36 | export function getListAppendExpressionAttributes( 37 | key: string, 38 | value: IDynoexprInputValue 39 | ) { 40 | const [, listAppendValues] = /list_append\((.+)\)/.exec(`${value}`) || []; 41 | const rg = /(\[[^\]]+\])/g; // match [1, 2] 42 | 43 | return Array.from(listAppendValues.matchAll(rg)) 44 | .map((m) => m[0]) 45 | .filter((v) => v !== key) 46 | .flatMap((list) => fromStrListToArray(list)); 47 | } 48 | 49 | export function getListAppendExpression( 50 | key: string, 51 | value: IDynoexprInputValue 52 | ) { 53 | const attr = getAttrName(key); 54 | const [, listAppendValues] = /list_append\((.+)\)/.exec(`${value}`) || []; 55 | 56 | const rg = /(\[[^\]]+\])/g; 57 | const lists = Array.from(listAppendValues.matchAll(rg)).map((m) => m[0]); 58 | const attrValues: Record = {}; 59 | 60 | // replace only lists with attrValues 61 | const newValue = lists.reduce((acc, list) => { 62 | const listValues = fromStrListToArray(list); 63 | attrValues[list] = getAttrValue(listValues); 64 | return acc.replace(list, attrValues[list]); 65 | }, listAppendValues as string); 66 | 67 | const vv = newValue 68 | .split(/,/) 69 | .map((v) => v.trim()) 70 | .map((v) => (v === key ? attr : v)); 71 | 72 | return `${attr} = list_append(${vv.join(", ")})`; 73 | } 74 | 75 | interface IExpressionAttributesMap { 76 | ExpressionAttributeNames: { [key: string]: string }; 77 | ExpressionAttributeValues: { [key: string]: IDynoexprInputValue }; 78 | } 79 | 80 | export function getExpressionAttributes(params: IUpdateInput) { 81 | const { Update = {}, UpdateAction = "SET" } = params; 82 | 83 | return Object.entries(Update).reduce((acc, [key, value]) => { 84 | if (!acc.ExpressionAttributeNames) acc.ExpressionAttributeNames = {}; 85 | if (!acc.ExpressionAttributeValues) acc.ExpressionAttributeValues = {}; 86 | 87 | splitByDot(key).forEach((k) => { 88 | acc.ExpressionAttributeNames[getSingleAttrName(k)] = k; 89 | }); 90 | 91 | if (UpdateAction !== "REMOVE") { 92 | let v: IDynoexprInputValue | IDynoexprInputValue[] = value; 93 | 94 | if (isMathExpression(key, value)) { 95 | v = parseOperationValue(value as string, key); 96 | } 97 | 98 | if (/^if_not_exists/.test(`${value}`)) { 99 | const [, vv] = /if_not_exists\((.+)\)/.exec(`${value}`) || []; 100 | v = vv; 101 | } 102 | 103 | if (/^list_append/.test(`${value}`)) { 104 | v = getListAppendExpressionAttributes(key, value); 105 | } 106 | 107 | if (Array.isArray(v) && /ADD|DELETE/.test(UpdateAction)) { 108 | const s = new Set(v as string[]); 109 | acc.ExpressionAttributeValues[getAttrValue(s)] = s; 110 | } else { 111 | // @ts-expect-error foobar 112 | acc.ExpressionAttributeValues[getAttrValue(v)] = v; 113 | } 114 | } 115 | 116 | return acc; 117 | }, params as IExpressionAttributesMap); 118 | } 119 | 120 | export function getUpdateExpression(params: IUpdateInput = {}) { 121 | if (!params.Update) return params; 122 | 123 | const { Update, UpdateAction = "SET", ...restOfParams } = params; 124 | const { ExpressionAttributeNames = {}, ExpressionAttributeValues = {} } = 125 | getExpressionAttributes(params); 126 | 127 | let entries = ""; 128 | switch (UpdateAction) { 129 | case "SET": 130 | entries = Object.entries(Update) 131 | .map(([name, value]) => { 132 | if (/^if_not_exists/.test(`${value}`)) { 133 | const attr = getAttrName(name); 134 | const [, v] = /if_not_exists\((.+)\)/.exec(`${value}`) || []; 135 | return `${attr} = if_not_exists(${attr}, ${getAttrValue(v)})`; 136 | } 137 | 138 | if (/^list_append/.test(`${value}`)) { 139 | return getListAppendExpression(name, value); 140 | } 141 | 142 | if (isMathExpression(name, value)) { 143 | const [, operator] = /(\s-|-\s|[+])/.exec(value as string) || []; 144 | const val = value?.toString() || "unknown"; 145 | const operands = []; 146 | if (/\+/.test(val)) { 147 | operands.push(...splitByOperator("+", val)); 148 | } else if (/-/.test(val)) { 149 | operands.push(...splitByOperator("-", val)); 150 | } 151 | 152 | const expr = operands 153 | .map((operand: string) => operand.trim()) 154 | .map((operand: string) => { 155 | if (operand === name) return getAttrName(name); 156 | const v = parseOperationValue(operand, name); 157 | 158 | return getAttrValue(v); 159 | }) 160 | .join(` ${operator?.trim()} `); 161 | 162 | return `${getAttrName(name)} = ${expr}`; 163 | } 164 | 165 | return `${getAttrName(name)} = ${getAttrValue(value)}`; 166 | }) 167 | .join(", "); 168 | break; 169 | case "ADD": 170 | case "DELETE": 171 | entries = Object.entries(Update) 172 | .map( 173 | ([name, value]) => 174 | [ 175 | name, 176 | Array.isArray(value) ? new Set(value as unknown[]) : value, 177 | ] as [string, unknown] 178 | ) 179 | .map(([name, value]) => [getAttrName(name), getAttrValue(value)]) 180 | .map(([exprName, exprValue]) => `${exprName} ${exprValue}`) 181 | .join(", "); 182 | break; 183 | case "REMOVE": 184 | entries = Object.entries(Update) 185 | .map(([name]) => [getAttrName(name)]) 186 | .join(", "); 187 | break; 188 | default: 189 | break; 190 | } 191 | 192 | const parameters: IUpdateOutput = { 193 | ...restOfParams, 194 | UpdateExpression: [UpdateAction, entries].join(" "), 195 | ExpressionAttributeNames, 196 | ExpressionAttributeValues, 197 | }; 198 | 199 | return parameters; 200 | } 201 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { DocumentClient } from "aws-sdk/clients/dynamodb"; 2 | 3 | import type { IDynoexprOutput } from "src/dynoexpr.d"; 4 | 5 | import dynoexpr from "./index"; 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 8 | function assertType(): void { 9 | expect.anything(); 10 | } 11 | 12 | describe("high level API", () => { 13 | it("creates DynamoDb parameters", () => { 14 | const actual = dynoexpr({ 15 | KeyCondition: { id: "567" }, 16 | Condition: { rating: "> 4.5" }, 17 | Filter: { color: "blue" }, 18 | Projection: ["weight", "size"], 19 | }); 20 | 21 | const expected = { 22 | ConditionExpression: "(#n0f1c2905 > :vc95fafc8)", 23 | ExpressionAttributeNames: { 24 | "#n0367c420": "size", 25 | "#n2d334799": "color", 26 | "#nca40fdf5": "id", 27 | "#n0f1c2905": "rating", 28 | "#neb86488e": "weight", 29 | }, 30 | ExpressionAttributeValues: { 31 | ":v8dcca6b2": "567", 32 | ":v792aabee": "blue", 33 | ":vc95fafc8": 4.5, 34 | }, 35 | FilterExpression: "(#n2d334799 = :v792aabee)", 36 | KeyConditionExpression: "(#nca40fdf5 = :v8dcca6b2)", 37 | ProjectionExpression: "#neb86488e,#n0367c420", 38 | }; 39 | expect(actual).toStrictEqual(expected); 40 | }); 41 | 42 | it("doesn't require a type to be provided", () => { 43 | const args = dynoexpr({ 44 | TableName: "Table", 45 | Key: 1, 46 | UpdateSet: { color: "pink" }, 47 | }); 48 | 49 | assertType(); 50 | expect(args.TableName).toBe("Table"); 51 | }); 52 | 53 | it("accepts a type to be applied to the output", () => { 54 | const args = dynoexpr({ 55 | TableName: "Table", 56 | Key: 123, 57 | UpdateSet: { color: "pink" }, 58 | }); 59 | 60 | assertType(); 61 | expect(args.Key).toBe(123); 62 | }); 63 | 64 | it("throws an error if it's working with Sets but doesn't have DocumentClient", () => { 65 | const fn = () => dynoexpr({ Update: { color: new Set(["blue"]) } }); 66 | 67 | const expected = 68 | "dynoexpr: When working with Sets, please provide the AWS DocumentClient (v2)."; 69 | expect(fn).toThrowError(expected); 70 | }); 71 | 72 | it("accepts a provided DocumentClient (v2) for working with Sets", () => { 73 | const docClient = new DocumentClient(); 74 | const color = new Set(["blue", "yellow"]); 75 | const actual = dynoexpr({ 76 | UpdateSet: { color }, 77 | DocumentClient, 78 | }); 79 | 80 | const expected = { 81 | ExpressionAttributeNames: { "#n2d334799": "color" }, 82 | ExpressionAttributeValues: { 83 | ":ve325d039": docClient.createSet(["blue", "yellow"]), 84 | }, 85 | UpdateExpression: "SET #n2d334799 = :ve325d039", 86 | }; 87 | expect(actual).toStrictEqual(expected); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | IBatchRequestInput, 3 | IDynoexprInput, 4 | IDynoexprOutput, 5 | ITransactRequestInput, 6 | } from "./dynoexpr.d"; 7 | 8 | import { getBatchExpressions, isBatchRequest } from "./operations/batch"; 9 | import { getSingleTableExpressions } from "./operations/single"; 10 | import { 11 | getTransactExpressions, 12 | isTransactRequest, 13 | } from "./operations/transact"; 14 | import { AwsSdkDocumentClient } from "./document-client"; 15 | 16 | export type { 17 | IBatchRequestInput, 18 | IDynoexprInput, 19 | IDynoexprOutput, 20 | ITransactRequestInput, 21 | }; 22 | 23 | export interface IDynoexprArgs 24 | extends Partial, 25 | Partial, 26 | Partial { 27 | DocumentClient?: unknown; 28 | } 29 | 30 | function cleanOutput(output: unknown) { 31 | const { DocumentClient, ...restOfOutput } = (output || {}) as { 32 | [key: string]: unknown; 33 | }; 34 | 35 | return restOfOutput as T; 36 | } 37 | 38 | function dynoexpr(args: IDynoexprArgs): T { 39 | if (args.DocumentClient) { 40 | AwsSdkDocumentClient.setDocumentClient(args.DocumentClient); 41 | } 42 | 43 | let returns: unknown; 44 | 45 | if (isBatchRequest(args)) { 46 | returns = getBatchExpressions(args) as IDynoexprOutput; 47 | } 48 | 49 | if (isTransactRequest(args)) { 50 | returns = getTransactExpressions(args) as IDynoexprOutput; 51 | } 52 | 53 | returns = getSingleTableExpressions(args); 54 | 55 | return cleanOutput(returns); 56 | } 57 | 58 | export default dynoexpr; 59 | -------------------------------------------------------------------------------- /src/operations/batch.test.ts: -------------------------------------------------------------------------------- 1 | import { getBatchExpressions } from "./batch"; 2 | 3 | describe("batch requests", () => { 4 | it("accepts batch operations: batchGet", () => { 5 | const args = { 6 | RequestItems: { 7 | "Table-1": { 8 | Keys: [{ foo: "bar" }], 9 | Projection: ["a", "b"], 10 | }, 11 | "Table-2": { 12 | Keys: [{ foo: "bar" }], 13 | Projection: ["foo", "cast", "year", "baz"], 14 | ExpressionAttributeNames: { 15 | "#quz": "quz", 16 | }, 17 | }, 18 | }, 19 | }; 20 | const actual = getBatchExpressions(args); 21 | 22 | const expected = { 23 | RequestItems: { 24 | "Table-1": { 25 | Keys: [{ foo: "bar" }], 26 | ProjectionExpression: "#na0f0d7ff,#ne4645342", 27 | ExpressionAttributeNames: { 28 | "#na0f0d7ff": "a", 29 | "#ne4645342": "b", 30 | }, 31 | }, 32 | "Table-2": { 33 | Keys: [{ foo: "bar" }], 34 | ProjectionExpression: "#n5f0025bb,#n66d7cb7d,#n645820bf,#n82504b33", 35 | ExpressionAttributeNames: { 36 | "#quz": "quz", 37 | "#n5f0025bb": "foo", 38 | "#n66d7cb7d": "cast", 39 | "#n645820bf": "year", 40 | "#n82504b33": "baz", 41 | }, 42 | }, 43 | }, 44 | }; 45 | expect(actual).toStrictEqual(expected); 46 | }); 47 | 48 | it("accepts batch operations: batchWrite", () => { 49 | const args = { 50 | RequestItems: { 51 | "Table-1": [{ DeleteRequest: { Key: { foo: "bar" } } }], 52 | "Table-2": [{ PutRequest: { Key: { foo: "bar" } } }], 53 | "Table-3": [ 54 | { PutRequest: { Item: { baz: "buz" } } }, 55 | { PutRequest: { Item: { biz: "quz" } } }, 56 | ], 57 | "Table-4": [ 58 | { DeleteRequest: { Item: { baz: "buz" } } }, 59 | { DeleteRequest: { Item: { biz: "quz" } } }, 60 | ], 61 | "Table-5": [ 62 | { PutRequest: { Item: { baz: "buz" } } }, 63 | { DeleteRequest: { Item: { biz: "quz" } } }, 64 | ], 65 | }, 66 | }; 67 | const actual = getBatchExpressions(args); 68 | 69 | expect(actual).toStrictEqual(args); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/operations/batch.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import type { 3 | IDynoexprInput, 4 | IBatchRequestInput, 5 | IBatchRequestOutput, 6 | IBatchRequestItemsInput, 7 | IBatchGetInput, 8 | IBatchWriteInput, 9 | } from "src/dynoexpr.d"; 10 | 11 | import { getSingleTableExpressions } from "./single"; 12 | 13 | export function isBatchRequest( 14 | params: IDynoexprInput | IBatchRequestInput 15 | ): params is IBatchRequestInput { 16 | return "RequestItems" in params; 17 | } 18 | 19 | function isBatchGetRequest( 20 | tableParams: IBatchGetInput | IBatchWriteInput[] 21 | ): tableParams is IBatchGetInput { 22 | return !Array.isArray(tableParams); 23 | } 24 | 25 | function isBatchWriteRequest( 26 | tableParams: IBatchGetInput | IBatchWriteInput[] 27 | ): tableParams is IBatchWriteInput[] { 28 | if (!Array.isArray(tableParams)) { 29 | return false; 30 | } 31 | 32 | const [firstTable] = tableParams; 33 | return "DeleteRequest" in firstTable || "PutRequest" in firstTable; 34 | } 35 | 36 | export function getBatchExpressions< 37 | T extends IBatchRequestOutput = IBatchRequestOutput, 38 | >(params: IBatchRequestInput): T { 39 | const RequestItems = Object.entries(params.RequestItems).reduce( 40 | (accParams, [tableName, tableParams]) => { 41 | if (isBatchGetRequest(tableParams)) { 42 | accParams[tableName] = getSingleTableExpressions(tableParams); 43 | } 44 | 45 | if (isBatchWriteRequest(tableParams)) { 46 | accParams[tableName] = tableParams; 47 | } 48 | 49 | return accParams; 50 | }, 51 | {} as IBatchRequestItemsInput 52 | ); 53 | 54 | return { 55 | ...params, 56 | RequestItems, 57 | } as T; 58 | } 59 | -------------------------------------------------------------------------------- /src/operations/helpers.ts: -------------------------------------------------------------------------------- 1 | import type { IDynoexprOutput } from "../dynoexpr"; 2 | 3 | export function trimEmptyExpressionAttributes< 4 | T extends IDynoexprOutput = IDynoexprOutput, 5 | >(expression: T): T { 6 | const trimmed = { ...expression }; 7 | const { ExpressionAttributeNames, ExpressionAttributeValues } = expression; 8 | 9 | if (Object.keys(ExpressionAttributeNames || {}).length === 0) { 10 | delete trimmed.ExpressionAttributeNames; 11 | } 12 | 13 | if (Object.keys(ExpressionAttributeValues || {}).length === 0) { 14 | delete trimmed.ExpressionAttributeValues; 15 | } 16 | 17 | return trimmed; 18 | } 19 | -------------------------------------------------------------------------------- /src/operations/single.test.ts: -------------------------------------------------------------------------------- 1 | import { DocumentClient as DocClientV2 } from "aws-sdk/clients/dynamodb"; 2 | 3 | import type { IDynoexprInput, IDynoexprOutput } from "src/dynoexpr.d"; 4 | import { AwsSdkDocumentClient } from "src/document-client"; 5 | 6 | import { 7 | getSingleTableExpressions, 8 | convertValuesToDynamoDbSet, 9 | } from "./single"; 10 | 11 | describe("single table operations", () => { 12 | it("applies consecutive expression getters to a parameters object", () => { 13 | const args: IDynoexprInput = { 14 | KeyCondition: { c: 5 }, 15 | Condition: { b: "> 10" }, 16 | Filter: { a: "foo" }, 17 | Projection: ["a", "b"], 18 | UpdateSet: { d: 7 }, 19 | UpdateAdd: { e: 8 }, 20 | UpdateDelete: { f: 9 }, 21 | UpdateRemove: { g: "g" }, 22 | }; 23 | const actual = getSingleTableExpressions(args); 24 | 25 | const expected: IDynoexprOutput = { 26 | ConditionExpression: "(#ne4645342 > :va8d1f941)", 27 | FilterExpression: "(#na0f0d7ff = :v5f0025bb)", 28 | KeyConditionExpression: "(#n54601b21 = :v77e3e295)", 29 | ProjectionExpression: "#na0f0d7ff,#ne4645342", 30 | UpdateExpression: 31 | "SET #nae599c14 = :v11392247 REMOVE #n42f580fe ADD #n7c866780 :v48aa77a3 DELETE #n79761749 :vf489a8ba", 32 | ExpressionAttributeNames: { 33 | "#nae599c14": "d", 34 | "#n79761749": "f", 35 | "#n42f580fe": "g", 36 | "#n54601b21": "c", 37 | "#na0f0d7ff": "a", 38 | "#ne4645342": "b", 39 | "#n7c866780": "e", 40 | }, 41 | ExpressionAttributeValues: { 42 | ":v48aa77a3": 8, 43 | ":v11392247": 7, 44 | ":v77e3e295": 5, 45 | ":vf489a8ba": 9, 46 | ":v5f0025bb": "foo", 47 | ":va8d1f941": 10, 48 | }, 49 | }; 50 | expect(actual).toStrictEqual(expected); 51 | }); 52 | 53 | it.each<[string, IDynoexprInput, IDynoexprOutput]>([ 54 | [ 55 | "UpdateRemove", 56 | { UpdateRemove: { a: "" } }, 57 | { 58 | UpdateExpression: "REMOVE #na0f0d7ff", 59 | ExpressionAttributeNames: { 60 | "#na0f0d7ff": "a", 61 | }, 62 | }, 63 | ], 64 | [ 65 | "UpdateAction: 'REMOVE'", 66 | { Update: { a: "" }, UpdateAction: "REMOVE" }, 67 | { 68 | UpdateExpression: "REMOVE #na0f0d7ff", 69 | ExpressionAttributeNames: { 70 | "#na0f0d7ff": "a", 71 | }, 72 | }, 73 | ], 74 | [ 75 | "UpdateRemove with Projection", 76 | { UpdateRemove: { foo: 1 }, Projection: ["bar"] }, 77 | { 78 | UpdateExpression: "REMOVE #n5f0025bb", 79 | ExpressionAttributeNames: { 80 | "#n22f4f0ae": "bar", 81 | "#n5f0025bb": "foo", 82 | }, 83 | ProjectionExpression: "#n22f4f0ae", 84 | }, 85 | ], 86 | ])("doesn't include ExpressionAttributeValues: %s", (_, args, expected) => { 87 | const actual = getSingleTableExpressions(args); 88 | expect(actual).toStrictEqual(expected); 89 | }); 90 | 91 | it("doesn't clash values for different expressions", () => { 92 | const args: IDynoexprInput = { 93 | KeyCondition: { a: 5 }, 94 | Condition: { a: "> 10" }, 95 | Filter: { a: 2 }, 96 | Projection: ["a", "b"], 97 | UpdateSet: { a: 2 }, 98 | }; 99 | const actual = getSingleTableExpressions(args); 100 | 101 | const expected: IDynoexprOutput = { 102 | FilterExpression: "(#na0f0d7ff = :vaeeabc63)", 103 | KeyConditionExpression: "(#na0f0d7ff = :v77e3e295)", 104 | ProjectionExpression: "#na0f0d7ff,#ne4645342", 105 | UpdateExpression: "SET #na0f0d7ff = :vaeeabc63", 106 | ConditionExpression: "(#na0f0d7ff > :va8d1f941)", 107 | ExpressionAttributeNames: { 108 | "#na0f0d7ff": "a", 109 | "#ne4645342": "b", 110 | }, 111 | ExpressionAttributeValues: { 112 | ":v77e3e295": 5, 113 | ":vaeeabc63": 2, 114 | ":va8d1f941": 10, 115 | }, 116 | }; 117 | expect(actual).toStrictEqual(expected); 118 | }); 119 | 120 | it("keeps existing Names/Values", () => { 121 | const args: IDynoexprInput = { 122 | KeyCondition: { a: 5 }, 123 | Condition: { a: "> 10" }, 124 | Filter: { a: 2 }, 125 | Projection: ["a", "b"], 126 | UpdateSet: { a: 2 }, 127 | ExpressionAttributeNames: { 128 | "#foo": "foo", 129 | }, 130 | ExpressionAttributeValues: { 131 | ":foo": "bar", 132 | }, 133 | }; 134 | const actual = getSingleTableExpressions(args); 135 | 136 | const expected = { 137 | KeyConditionExpression: "(#na0f0d7ff = :v77e3e295)", 138 | ConditionExpression: "(#na0f0d7ff > :va8d1f941)", 139 | FilterExpression: "(#na0f0d7ff = :vaeeabc63)", 140 | ProjectionExpression: "#na0f0d7ff,#ne4645342", 141 | UpdateExpression: "SET #na0f0d7ff = :vaeeabc63", 142 | ExpressionAttributeNames: { 143 | "#na0f0d7ff": "a", 144 | "#ne4645342": "b", 145 | "#foo": "foo", 146 | }, 147 | ExpressionAttributeValues: { 148 | ":v77e3e295": 5, 149 | ":vaeeabc63": 2, 150 | ":va8d1f941": 10, 151 | ":foo": "bar", 152 | }, 153 | }; 154 | expect(actual).toStrictEqual(expected); 155 | }); 156 | 157 | describe("documentClient Sets", () => { 158 | it("converts Sets to DynamoDbSet if present in ExpressionsAttributeValues", () => { 159 | const values = { 160 | a: 1, 161 | b: "foo", 162 | c: [1, 2, 3], 163 | d: { foo: "bar" }, 164 | e: new Set([1, 2]), 165 | f: new Set(["foo", "bar"]), 166 | }; 167 | AwsSdkDocumentClient.setDocumentClient(DocClientV2); 168 | const sdk = new AwsSdkDocumentClient(); 169 | const actual = convertValuesToDynamoDbSet(values); 170 | 171 | const expected = { 172 | a: 1, 173 | b: "foo", 174 | c: [1, 2, 3], 175 | d: { foo: "bar" }, 176 | e: sdk.createSet([1, 2]), 177 | f: sdk.createSet(["foo", "bar"]), 178 | }; 179 | expect(actual).toStrictEqual(expected); 180 | }); 181 | }); 182 | }); 183 | -------------------------------------------------------------------------------- /src/operations/single.ts: -------------------------------------------------------------------------------- 1 | import { AwsSdkDocumentClient } from "src/document-client"; 2 | import type { 3 | IDynamoDbValue, 4 | IDynoexprInput, 5 | IDynoexprOutput, 6 | } from "src/dynoexpr.d"; 7 | 8 | import { getConditionExpression } from "../expressions/condition"; 9 | import { getFilterExpression } from "../expressions/filter"; 10 | import { getKeyConditionExpression } from "../expressions/key-condition"; 11 | import { getProjectionExpression } from "../expressions/projection"; 12 | import { getUpdateExpression } from "../expressions/update"; 13 | import { getUpdateOperationsExpression } from "../expressions/update-ops"; 14 | 15 | import { trimEmptyExpressionAttributes } from "./helpers"; 16 | 17 | export function convertValuesToDynamoDbSet( 18 | attributeValues: Record 19 | ) { 20 | return Object.entries(attributeValues).reduce( 21 | (acc, [key, value]) => { 22 | if (value instanceof Set) { 23 | const sdk = new AwsSdkDocumentClient(); 24 | acc[key] = sdk.createSet(Array.from(value)); 25 | } else { 26 | acc[key] = value as IDynamoDbValue; 27 | } 28 | return acc; 29 | }, 30 | {} as Record 31 | ); 32 | } 33 | 34 | export function getSingleTableExpressions< 35 | T extends IDynoexprOutput = IDynoexprOutput, 36 | >(params: IDynoexprInput = {}): T { 37 | const expression = [ 38 | getKeyConditionExpression, 39 | getConditionExpression, 40 | getFilterExpression, 41 | getProjectionExpression, 42 | getUpdateExpression, 43 | getUpdateOperationsExpression, 44 | ].reduce((acc, getExpressionFn) => getExpressionFn(acc), params) as T; 45 | 46 | delete expression.Update; 47 | delete expression.UpdateAction; 48 | 49 | const { ExpressionAttributeValues = {} } = expression; 50 | if (Object.keys(ExpressionAttributeValues).length > 0) { 51 | expression.ExpressionAttributeValues = convertValuesToDynamoDbSet( 52 | ExpressionAttributeValues 53 | ); 54 | } 55 | 56 | return trimEmptyExpressionAttributes(expression); 57 | } 58 | -------------------------------------------------------------------------------- /src/operations/transact.test.ts: -------------------------------------------------------------------------------- 1 | import type { ITransactRequestInput } from "src/dynoexpr.d"; 2 | 3 | import { getTransactExpressions } from "./transact"; 4 | 5 | describe("transact requests", () => { 6 | it("accepts transact operations - transactGet", () => { 7 | const args = { 8 | TransactItems: [ 9 | { 10 | Get: { 11 | TableName: "Table-1", 12 | Key: { id: "foo" }, 13 | Projection: ["a", "b"], 14 | }, 15 | }, 16 | { 17 | Get: { 18 | TableName: "Table-2", 19 | Key: { id: "bar" }, 20 | Projection: ["foo", "cast", "year", "baz"], 21 | ExpressionAttributeNames: { 22 | "#quz": "quz", 23 | }, 24 | }, 25 | }, 26 | ], 27 | ReturnConsumedCapacity: "INDEXES", 28 | } as ITransactRequestInput; 29 | const actual = getTransactExpressions(args); 30 | 31 | const expected = { 32 | TransactItems: [ 33 | { 34 | Get: { 35 | TableName: "Table-1", 36 | Key: { id: "foo" }, 37 | ProjectionExpression: "#na0f0d7ff,#ne4645342", 38 | ExpressionAttributeNames: { 39 | "#na0f0d7ff": "a", 40 | "#ne4645342": "b", 41 | }, 42 | }, 43 | }, 44 | { 45 | Get: { 46 | TableName: "Table-2", 47 | Key: { id: "bar" }, 48 | ProjectionExpression: "#n5f0025bb,#n66d7cb7d,#n645820bf,#n82504b33", 49 | ExpressionAttributeNames: { 50 | "#quz": "quz", 51 | "#n5f0025bb": "foo", 52 | "#n66d7cb7d": "cast", 53 | "#n645820bf": "year", 54 | "#n82504b33": "baz", 55 | }, 56 | }, 57 | }, 58 | ], 59 | ReturnConsumedCapacity: "INDEXES", 60 | }; 61 | expect(actual).toStrictEqual(expected); 62 | }); 63 | 64 | it("accepts transact operations - transactWrite", () => { 65 | const args = { 66 | TransactItems: [ 67 | { 68 | ConditionCheck: { 69 | TableName: "Table-1", 70 | Condition: { a: "foo" }, 71 | }, 72 | }, 73 | { 74 | Put: { 75 | TableName: "Table-1", 76 | Condition: { b: "> 1" }, 77 | }, 78 | }, 79 | { 80 | Delete: { 81 | TableName: "Table-2", 82 | Condition: { c: ">= 2" }, 83 | }, 84 | }, 85 | { 86 | Update: { 87 | TableName: "Table-3", 88 | Update: { foo: "bar" }, 89 | }, 90 | }, 91 | ], 92 | ReturnConsumedCapacity: "INDEXES", 93 | }; 94 | const actual = getTransactExpressions(args); 95 | 96 | const expected = { 97 | ReturnConsumedCapacity: "INDEXES", 98 | TransactItems: [ 99 | { 100 | ConditionCheck: { 101 | ConditionExpression: "(#na0f0d7ff = :v5f0025bb)", 102 | ExpressionAttributeNames: { 103 | "#na0f0d7ff": "a", 104 | }, 105 | ExpressionAttributeValues: { 106 | ":v5f0025bb": "foo", 107 | }, 108 | TableName: "Table-1", 109 | }, 110 | }, 111 | { 112 | Put: { 113 | ConditionExpression: "(#ne4645342 > :vc823bd86)", 114 | ExpressionAttributeNames: { 115 | "#ne4645342": "b", 116 | }, 117 | ExpressionAttributeValues: { 118 | ":vc823bd86": 1, 119 | }, 120 | TableName: "Table-1", 121 | }, 122 | }, 123 | { 124 | Delete: { 125 | ConditionExpression: "(#n54601b21 >= :vaeeabc63)", 126 | ExpressionAttributeNames: { 127 | "#n54601b21": "c", 128 | }, 129 | ExpressionAttributeValues: { 130 | ":vaeeabc63": 2, 131 | }, 132 | TableName: "Table-2", 133 | }, 134 | }, 135 | { 136 | Update: { 137 | ExpressionAttributeNames: { 138 | "#n5f0025bb": "foo", 139 | }, 140 | ExpressionAttributeValues: { 141 | ":v22f4f0ae": "bar", 142 | }, 143 | TableName: "Table-3", 144 | UpdateExpression: "SET #n5f0025bb = :v22f4f0ae", 145 | }, 146 | }, 147 | ], 148 | }; 149 | expect(actual).toStrictEqual(expected); 150 | }); 151 | }); 152 | -------------------------------------------------------------------------------- /src/operations/transact.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | IDynoexprInput, 3 | ITransactOperation, 4 | ITransactRequestInput, 5 | ITransactRequestOutput, 6 | } from "src/dynoexpr.d"; 7 | 8 | import { getSingleTableExpressions } from "./single"; 9 | 10 | export function isTransactRequest( 11 | params: IDynoexprInput | ITransactRequestInput 12 | ): params is ITransactRequestInput { 13 | return "TransactItems" in params; 14 | } 15 | 16 | export function getTransactExpressions< 17 | T extends ITransactRequestOutput = ITransactRequestOutput, 18 | >(params: ITransactRequestInput): T { 19 | const TransactItems = params.TransactItems.map((tableItems) => { 20 | const [key] = Object.keys(tableItems) as ITransactOperation[]; 21 | return { 22 | [key]: getSingleTableExpressions(tableItems[key]), 23 | }; 24 | }); 25 | 26 | return { 27 | ...params, 28 | TransactItems, 29 | } as T; 30 | } 31 | -------------------------------------------------------------------------------- /src/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getAttrName, 3 | getAttrValue, 4 | md5, 5 | splitByDot, 6 | splitByOperator, 7 | toString, 8 | unquote, 9 | } from "./utils"; 10 | 11 | describe("expression helpers", () => { 12 | it.each([ 13 | ["string", "foo", "foo:string"], 14 | ["number", 2, "2:number"], 15 | ["boolean", false, "false:boolean"], 16 | ["null", null, "null"], 17 | ["undefined", undefined, "undefined:undefined"], 18 | ["object", { foo: "bar" }, '{"foo":"bar"}'], 19 | ["array", ["foo", "bar"], '["foo","bar"]'], 20 | ["set of numbers", new Set([1, 2]), "Set([1,2]))"], 21 | ["set of strings", new Set(["foo", "bar"]), 'Set(["foo","bar"]))'], 22 | ])("converts to string - %s", (_, value, expected) => { 23 | const result = toString(value); 24 | expect(result).toBe(expected); 25 | }); 26 | 27 | it.each([ 28 | ["string", "foo", "50160593616221462b570f645f0025bb"], 29 | ["number", 2, "8203d52a3428fce53b2d8b84aeeabc63"], 30 | ["boolean", false, "5c6c5c6ca9032dd615c9d4ef976fa742"], 31 | ["null", null, "37a6259cc0c1dae299a7866489dff0bd"], 32 | ["undefined", undefined, "311128a3ab878d27e98b4f68914aa64e"], 33 | ["object", { foo: "bar" }, "9bb58f26192e4ba00f01e2e7b136bbd8"], 34 | ["array", ["foo", "bar"], "1ea13cb52ddd7c90e9f428d1df115d8f"], 35 | ["set of numbers", new Set([1, 2]), "8c627cc9d533e8fa591e2687101cd26b"], 36 | ["set of strings", new Set(["foo"]), "a4c6dd1467761291b805998fe24e60df"], 37 | ])("hashes any value - %s", (_, value, expected) => { 38 | const result = md5(value); 39 | expect(result).toBe(expected); 40 | }); 41 | 42 | it.each([ 43 | ['"foobar"', "foobar"], 44 | ['"foobar', "foobar"], 45 | ['foobar"', "foobar"], 46 | ])("unquote: %s", (input, expected) => { 47 | const actual = unquote(input); 48 | expect(actual).toBe(expected); 49 | }); 50 | 51 | it.each([ 52 | ["foo.bar.baz", ["foo", "bar", "baz"]], 53 | ['foo."bar.buz".baz', ["foo", "bar.buz", "baz"]], 54 | ['foo."bar.buz.baz"', ["foo", "bar.buz.baz"]], 55 | ['"foo.bar.buz".baz', ["foo.bar.buz", "baz"]], 56 | ['"foo.bar.buz.baz"', ["foo.bar.buz.baz"]], 57 | ])("split by dot: %s", (input, expected) => { 58 | const actual = splitByDot(input); 59 | expect(actual).toStrictEqual(expected); 60 | }); 61 | 62 | it.each([ 63 | ["new attribute", "foo", "#n5f0025bb"], 64 | ["already encoded", "#foo", "#foo"], 65 | ["object keys", "object.key.value", "#nbb017076.#nefd6a199.#n10d6f4c5"], 66 | [ 67 | "object keys", 68 | 'object."key.with-chars".value', 69 | "#nbb017076.#n0327a04a.#n10d6f4c5", 70 | ], 71 | ])("creates expressions attributes names: %s", (_, attrib, expected) => { 72 | const result = getAttrName(attrib); 73 | expect(result).toBe(expected); 74 | }); 75 | 76 | it.each([ 77 | ["new value", "foo", ":v5f0025bb"], 78 | ["already encoded", ":foo", ":foo"], 79 | ])("creates expressions attributes values: %s", (_, attrib, expected) => { 80 | const result = getAttrValue(attrib); 81 | expect(result).toBe(expected); 82 | }); 83 | 84 | it.each([ 85 | ["+", "foo + 1", ["foo", "1"]], 86 | ["+", "1 + foo", ["1", "foo"]], 87 | ["-", "foo - 1", ["foo", "1"]], 88 | ["-", "1 - foo", ["1", "foo"]], 89 | ["+", "foo.bar + 1", ["foo.bar", "1"]], 90 | ["+", "1 + foo.bar", ["1", "foo.bar"]], 91 | ["-", "foo.bar - 1", ["foo.bar", "1"]], 92 | ["-", "1 - foo.bar", ["1", "foo.bar"]], 93 | ["+", "foo.bar.baz + 1", ["foo.bar.baz", "1"]], 94 | ["+", "1 + foo.bar.baz", ["1", "foo.bar.baz"]], 95 | ["-", "foo.bar.baz - 1", ["foo.bar.baz", "1"]], 96 | ["-", "1 - foo.bar.baz", ["1", "foo.bar.baz"]], 97 | ["+", 'foo."bar+buz".baz + 1', ['foo."bar+buz".baz', "1"]], 98 | ["+", '1 + foo."bar+buz".baz', ["1", 'foo."bar+buz".baz']], 99 | ["-", 'foo."bar+buz".baz - 1', ['foo."bar+buz".baz', "1"]], 100 | ["-", '1 - foo."bar+buz".baz', ["1", 'foo."bar+buz".baz']], 101 | ["+", 'foo."bar-buz".baz + 1', ['foo."bar-buz".baz', "1"]], 102 | ["+", '1 + foo."bar-buz".baz', ["1", 'foo."bar-buz".baz']], 103 | ["-", 'foo."bar-buz".baz - 1', ['foo."bar-buz".baz', "1"]], 104 | ["-", '1 - foo."bar-buz".baz', ["1", 'foo."bar-buz".baz']], 105 | ])("split by operator: %s, %s", (operator, input, expected) => { 106 | const actual = splitByOperator(operator, input); 107 | expect(actual).toStrictEqual(expected); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import crypto from "node:crypto"; 2 | 3 | export function toString(data: unknown) { 4 | if (data instanceof Set) { 5 | return `Set(${JSON.stringify(Array.from(data))}))`; 6 | } 7 | 8 | return typeof data === "object" 9 | ? JSON.stringify(data) 10 | : `${data}:${typeof data}`; 11 | } 12 | 13 | export function md5(data: unknown) { 14 | return crypto.createHash("md5").update(toString(data).trim()).digest("hex"); 15 | } 16 | 17 | function md5hash(data: unknown) { 18 | return md5(data).slice(24); 19 | } 20 | 21 | export function unquote(input: string) { 22 | return input.replace(/^"/, "").replace(/"$/, ""); 23 | } 24 | 25 | export function splitByDot(input: string) { 26 | const parts = input.match(/"[^"]+"|[^.]+/g) ?? []; 27 | 28 | return parts.map(unquote); 29 | } 30 | 31 | export function getSingleAttrName(attr: string) { 32 | return `#n${md5hash(attr)}`; 33 | } 34 | 35 | export function getAttrName(attribute: string) { 36 | if (/^#/.test(attribute)) return attribute; 37 | 38 | return splitByDot(attribute).map(getSingleAttrName).join("."); 39 | } 40 | 41 | export function getAttrValue(value: unknown) { 42 | if (typeof value === "string" && /^:/.test(value)) { 43 | return value; 44 | } 45 | 46 | return `:v${md5hash(value)}`; 47 | } 48 | 49 | export function splitByOperator(operator: string, input: string) { 50 | const rg = new RegExp(` [${operator}] `, "g"); 51 | 52 | return input 53 | .split(rg) 54 | .filter((m) => m !== operator) 55 | .map((m) => m.trim()) 56 | .map(unquote); 57 | } 58 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist" 5 | }, 6 | "exclude": ["node_modules/**/*", "**/*.test.ts", "sh"] 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "declaration": true, 5 | "esModuleInterop": true, 6 | "lib": ["dom"], 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "outDir": "./dist", 10 | "removeComments": true, 11 | "skipLibCheck": true, 12 | "strict": true, 13 | "types": ["vitest/globals"] 14 | }, 15 | "include": ["src/**/*", "src/dynoexpr.d.ts", "sh"], 16 | "exclude": ["node_modules/**/*"] 17 | } 18 | -------------------------------------------------------------------------------- /vite.config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | import path from "node:path"; 3 | 4 | export default defineConfig({ 5 | test: { 6 | globals: true, 7 | coverage: { 8 | reporter: ["lcov"], 9 | }, 10 | }, 11 | resolve: { 12 | alias: { 13 | src: path.resolve(__dirname, "./src/"), 14 | }, 15 | }, 16 | }); 17 | --------------------------------------------------------------------------------