├── .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 |

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